feat(layout): natural size
Add layout.Natural: the border-box size of a node tree without stretching, for sizing a surface before it exists.
- commit
- db490c8262e0d84d4cb92bf26898b7c833c98b0b
- author
- brice · · 2026-10-09 03:36 UTC
- parent
- aa12db3
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +184 -0
M internal/layout/layout.go +71 -0
| @@ -218,6 +218,77 @@ func Layout(root *Node, options Options) (Output, error) { | |||
| 218 | 218 | return out, nil | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 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, | |
| 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. | |
| 226 | + | func Natural(root *Node, options Options) (Size, error) { | |
| 227 | + | if root == nil { | |
| 228 | + | return Size{}, nil | |
| 229 | + | } | |
| 230 | + | ctx := newContext(options) | |
| 231 | + | s := style(root) | |
| 232 | + | if s.Display == css.KeywordNone { | |
| 233 | + | return Size{}, nil | |
| 234 | + | } | |
| 235 | + | if err := validateStackRects(root, s); err != nil { | |
| 236 | + | return Size{}, err | |
| 237 | + | } | |
| 238 | + | availableW, availableH := safe(options.Width), safe(options.Height) | |
| 239 | + | margin := edges(s.Margin, availableW) | |
| 240 | + | padding := nonnegative(edges(s.Padding, availableW)) | |
| 241 | + | border := nonnegative(edges(s.BorderWidth, availableW)) | |
| 242 | + | if s.BorderStyle.Top != css.KeywordSolid { | |
| 243 | + | border.Top = 0 | |
| 244 | + | } | |
| 245 | + | if s.BorderStyle.Right != css.KeywordSolid { | |
| 246 | + | border.Right = 0 | |
| 247 | + | } | |
| 248 | + | if s.BorderStyle.Bottom != css.KeywordSolid { | |
| 249 | + | border.Bottom = 0 | |
| 250 | + | } | |
| 251 | + | if s.BorderStyle.Left != css.KeywordSolid { | |
| 252 | + | border.Left = 0 | |
| 253 | + | } | |
| 254 | + | insetW := padding.horizontal() + border.horizontal() | |
| 255 | + | insetH := padding.vertical() + border.vertical() | |
| 256 | + | borderBox := s.BoxSizing == css.KeywordBorderBox | |
| 257 | + | w, explicitW := length(s.Width, availableW) | |
| 258 | + | h, explicitH := length(s.Height, availableH) | |
| 259 | + | if explicitW { | |
| 260 | + | w = bounded(w, s.MinWidth, s.MaxWidth, availableW) | |
| 261 | + | if !borderBox { | |
| 262 | + | w += insetW | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | if explicitH { | |
| 266 | + | h = bounded(h, s.MinHeight, s.MaxHeight, availableH) | |
| 267 | + | if !borderBox { | |
| 268 | + | h += insetH | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | contentW := safe(availableW - margin.horizontal() - insetW) | |
| 272 | + | if explicitW { | |
| 273 | + | contentW = safe(w - insetW) | |
| 274 | + | } | |
| 275 | + | measureW := contentW | |
| 276 | + | if root.Kind == Text && root.NoWrap { | |
| 277 | + | measureW = 0 | |
| 278 | + | } | |
| 279 | + | measured, _, err := ctx.intrinsicDepth(root, measureW, 1) | |
| 280 | + | if err != nil { | |
| 281 | + | return Size{}, err | |
| 282 | + | } | |
| 283 | + | if !explicitW { | |
| 284 | + | w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox) | |
| 285 | + | } | |
| 286 | + | if !explicitH { | |
| 287 | + | h = boxBound(measured.H+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |
| 288 | + | } | |
| 289 | + | return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil | |
| 290 | + | } | |
| 291 | + | ||
| 221 | 292 | // display paints the placed tree into a list sized once from the tree. | |
| 222 | 293 | func (c context) display(root *Node, tree *Result) []Command { | |
| 223 | 294 | n := countCommands(root, tree) | |
| @@ -218,6 +218,77 @@ func Layout(root *Node, options Options) (Output, error) { | |||||
| 218 | return out, nil | 218 | return out, nil | ||
| 219 | } | 219 | } | ||
| 220 | 220 | ||||
| 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, | |||
| 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. | |||
| 226 | + | func Natural(root *Node, options Options) (Size, error) { | |||
| 227 | + | if root == nil { | |||
| 228 | + | return Size{}, nil | |||
| 229 | + | } | |||
| 230 | + | ctx := newContext(options) | |||
| 231 | + | s := style(root) | |||
| 232 | + | if s.Display == css.KeywordNone { | |||
| 233 | + | return Size{}, nil | |||
| 234 | + | } | |||
| 235 | + | if err := validateStackRects(root, s); err != nil { | |||
| 236 | + | return Size{}, err | |||
| 237 | + | } | |||
| 238 | + | availableW, availableH := safe(options.Width), safe(options.Height) | |||
| 239 | + | margin := edges(s.Margin, availableW) | |||
| 240 | + | padding := nonnegative(edges(s.Padding, availableW)) | |||
| 241 | + | border := nonnegative(edges(s.BorderWidth, availableW)) | |||
| 242 | + | if s.BorderStyle.Top != css.KeywordSolid { | |||
| 243 | + | border.Top = 0 | |||
| 244 | + | } | |||
| 245 | + | if s.BorderStyle.Right != css.KeywordSolid { | |||
| 246 | + | border.Right = 0 | |||
| 247 | + | } | |||
| 248 | + | if s.BorderStyle.Bottom != css.KeywordSolid { | |||
| 249 | + | border.Bottom = 0 | |||
| 250 | + | } | |||
| 251 | + | if s.BorderStyle.Left != css.KeywordSolid { | |||
| 252 | + | border.Left = 0 | |||
| 253 | + | } | |||
| 254 | + | insetW := padding.horizontal() + border.horizontal() | |||
| 255 | + | insetH := padding.vertical() + border.vertical() | |||
| 256 | + | borderBox := s.BoxSizing == css.KeywordBorderBox | |||
| 257 | + | w, explicitW := length(s.Width, availableW) | |||
| 258 | + | h, explicitH := length(s.Height, availableH) | |||
| 259 | + | if explicitW { | |||
| 260 | + | w = bounded(w, s.MinWidth, s.MaxWidth, availableW) | |||
| 261 | + | if !borderBox { | |||
| 262 | + | w += insetW | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | if explicitH { | |||
| 266 | + | h = bounded(h, s.MinHeight, s.MaxHeight, availableH) | |||
| 267 | + | if !borderBox { | |||
| 268 | + | h += insetH | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | contentW := safe(availableW - margin.horizontal() - insetW) | |||
| 272 | + | if explicitW { | |||
| 273 | + | contentW = safe(w - insetW) | |||
| 274 | + | } | |||
| 275 | + | measureW := contentW | |||
| 276 | + | if root.Kind == Text && root.NoWrap { | |||
| 277 | + | measureW = 0 | |||
| 278 | + | } | |||
| 279 | + | measured, _, err := ctx.intrinsicDepth(root, measureW, 1) | |||
| 280 | + | if err != nil { | |||
| 281 | + | return Size{}, err | |||
| 282 | + | } | |||
| 283 | + | if !explicitW { | |||
| 284 | + | w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox) | |||
| 285 | + | } | |||
| 286 | + | if !explicitH { | |||
| 287 | + | h = boxBound(measured.H+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |||
| 288 | + | } | |||
| 289 | + | return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil | |||
| 290 | + | } | |||
| 291 | + | ||||
| 221 | // display paints the placed tree into a list sized once from the tree. | 292 | // display paints the placed tree into a list sized once from the tree. | ||
| 222 | func (c context) display(root *Node, tree *Result) []Command { | 293 | func (c context) display(root *Node, tree *Result) []Command { | ||
| 223 | n := countCommands(root, tree) | 294 | n := countCommands(root, tree) | ||
A internal/layout/natural_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | "github.com/bnema/nefergui/internal/text" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func naturalEngine(t *testing.T) *text.Engine { | |
| 11 | + | t.Helper() | |
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | return text.NewEngine(catalog) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func textNode(id, content string) *Node { | |
| 20 | + | n := node(id, Text, css.Style{Display: css.KeywordBlock, FontSize: px(16)}) | |
| 21 | + | n.Content = content | |
| 22 | + | return n | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestNaturalColumnOfTexts(t *testing.T) { | |
| 26 | + | engine := naturalEngine(t) | |
| 27 | + | short, long := textNode("a", "ab"), textNode("b", "a much longer line") | |
| 28 | + | 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) | |
| 29 | + | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | |
| 30 | + | got, err := Natural(column, opts) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | ws, err := Natural(short, opts) | |
| 35 | + | if err != nil { | |
| 36 | + | t.Fatal(err) | |
| 37 | + | } | |
| 38 | + | wl, err := Natural(long, opts) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | if !(wl.W > ws.W) || ws.W <= 0 { | |
| 43 | + | t.Fatalf("text widths short=%g long=%g", ws.W, wl.W) | |
| 44 | + | } | |
| 45 | + | if got.W != wl.W+12 || got.H != ws.H+wl.H+8 { | |
| 46 | + | t.Fatalf("column = %+v, want %g x %g", got, wl.W+12, ws.H+wl.H+8) | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestNaturalRowSumsWithGap(t *testing.T) { | |
| 51 | + | engine := naturalEngine(t) | |
| 52 | + | a, b := textNode("a", "alpha"), textNode("b", "beta") | |
| 53 | + | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | |
| 54 | + | wa, _ := Natural(a, opts) | |
| 55 | + | wb, _ := Natural(b, opts) | |
| 56 | + | row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b) | |
| 57 | + | got, err := Natural(row, opts) | |
| 58 | + | if err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | if got.W != wa.W+wb.W+10 || got.H != max(wa.H, wb.H) { | |
| 62 | + | t.Fatalf("row = %+v, want W %g H %g", got, wa.W+wb.W+10, max(wa.H, wb.H)) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func TestNaturalExplicitWidthWins(t *testing.T) { | |
| 67 | + | engine := naturalEngine(t) | |
| 68 | + | child := textNode("a", "alpha beta gamma delta epsilon zeta") | |
| 69 | + | box := node("box", Box, css.Style{Display: css.KeywordBlock, Width: px(90), Padding: css.LengthSides{Left: px(5), Right: px(5)}}, child) | |
| 70 | + | got, err := Natural(box, Options{Width: 1000, Height: 1 << 20, TextEngine: engine}) | |
| 71 | + | if err != nil { | |
| 72 | + | t.Fatal(err) | |
| 73 | + | } | |
| 74 | + | if got.W != 100 { | |
| 75 | + | t.Fatalf("width = %g, want 100 (90 content + 10 padding)", got.W) | |
| 76 | + | } | |
| 77 | + | if got.H <= 16 { | |
| 78 | + | t.Fatalf("content did not wrap inside the explicit width: height %g", got.H) | |
| 79 | + | } | |
| 80 | + | 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)}}) | |
| 81 | + | got, err = Natural(bb, Options{Width: 1000, Height: 1000}) | |
| 82 | + | if err != nil || got.W != 90 || got.H != 30 { | |
| 83 | + | t.Fatalf("border-box = %+v err=%v", got, err) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestNaturalTextWrapsAtOptionsWidth(t *testing.T) { | |
| 88 | + | engine := naturalEngine(t) | |
| 89 | + | long := textNode("a", "alpha beta gamma delta epsilon zeta eta theta iota kappa") | |
| 90 | + | wide, err := Natural(long, Options{Width: 2000, Height: 1 << 20, TextEngine: engine}) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | narrow, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine}) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | // A wrapped line may measure slightly over the limit (103.75 for 100 here); | |
| 99 | + | // callers clamp to the limit, as Renderer.Measure does. | |
| 100 | + | if narrow.W >= wide.W || narrow.H <= wide.H { | |
| 101 | + | t.Fatalf("narrow=%+v wide=%+v", narrow, wide) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestNaturalNilAndHidden(t *testing.T) { | |
| 106 | + | if got, err := Natural(nil, Options{}); err != nil || got != (Size{}) { | |
| 107 | + | t.Fatalf("nil: %+v %v", got, err) | |
| 108 | + | } | |
| 109 | + | hidden := node("h", Box, css.Style{Display: css.KeywordNone}) | |
| 110 | + | if got, err := Natural(hidden, Options{Width: 100}); err != nil || got != (Size{}) { | |
| 111 | + | t.Fatalf("hidden: %+v %v", got, err) | |
| 112 | + | } | |
| 113 | + | } | |
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |||
| 7 | + | "github.com/bnema/nefergui/internal/text" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func naturalEngine(t *testing.T) *text.Engine { | |||
| 11 | + | t.Helper() | |||
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | return text.NewEngine(catalog) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func textNode(id, content string) *Node { | |||
| 20 | + | n := node(id, Text, css.Style{Display: css.KeywordBlock, FontSize: px(16)}) | |||
| 21 | + | n.Content = content | |||
| 22 | + | return n | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestNaturalColumnOfTexts(t *testing.T) { | |||
| 26 | + | engine := naturalEngine(t) | |||
| 27 | + | short, long := textNode("a", "ab"), textNode("b", "a much longer line") | |||
| 28 | + | 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) | |||
| 29 | + | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | |||
| 30 | + | got, err := Natural(column, opts) | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | ws, err := Natural(short, opts) | |||
| 35 | + | if err != nil { | |||
| 36 | + | t.Fatal(err) | |||
| 37 | + | } | |||
| 38 | + | wl, err := Natural(long, opts) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | if !(wl.W > ws.W) || ws.W <= 0 { | |||
| 43 | + | t.Fatalf("text widths short=%g long=%g", ws.W, wl.W) | |||
| 44 | + | } | |||
| 45 | + | if got.W != wl.W+12 || got.H != ws.H+wl.H+8 { | |||
| 46 | + | t.Fatalf("column = %+v, want %g x %g", got, wl.W+12, ws.H+wl.H+8) | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func TestNaturalRowSumsWithGap(t *testing.T) { | |||
| 51 | + | engine := naturalEngine(t) | |||
| 52 | + | a, b := textNode("a", "alpha"), textNode("b", "beta") | |||
| 53 | + | opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine} | |||
| 54 | + | wa, _ := Natural(a, opts) | |||
| 55 | + | wb, _ := Natural(b, opts) | |||
| 56 | + | row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b) | |||
| 57 | + | got, err := Natural(row, opts) | |||
| 58 | + | if err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | if got.W != wa.W+wb.W+10 || got.H != max(wa.H, wb.H) { | |||
| 62 | + | t.Fatalf("row = %+v, want W %g H %g", got, wa.W+wb.W+10, max(wa.H, wb.H)) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func TestNaturalExplicitWidthWins(t *testing.T) { | |||
| 67 | + | engine := naturalEngine(t) | |||
| 68 | + | child := textNode("a", "alpha beta gamma delta epsilon zeta") | |||
| 69 | + | box := node("box", Box, css.Style{Display: css.KeywordBlock, Width: px(90), Padding: css.LengthSides{Left: px(5), Right: px(5)}}, child) | |||
| 70 | + | got, err := Natural(box, Options{Width: 1000, Height: 1 << 20, TextEngine: engine}) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatal(err) | |||
| 73 | + | } | |||
| 74 | + | if got.W != 100 { | |||
| 75 | + | t.Fatalf("width = %g, want 100 (90 content + 10 padding)", got.W) | |||
| 76 | + | } | |||
| 77 | + | if got.H <= 16 { | |||
| 78 | + | t.Fatalf("content did not wrap inside the explicit width: height %g", got.H) | |||
| 79 | + | } | |||
| 80 | + | 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)}}) | |||
| 81 | + | got, err = Natural(bb, Options{Width: 1000, Height: 1000}) | |||
| 82 | + | if err != nil || got.W != 90 || got.H != 30 { | |||
| 83 | + | t.Fatalf("border-box = %+v err=%v", got, err) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestNaturalTextWrapsAtOptionsWidth(t *testing.T) { | |||
| 88 | + | engine := naturalEngine(t) | |||
| 89 | + | long := textNode("a", "alpha beta gamma delta epsilon zeta eta theta iota kappa") | |||
| 90 | + | wide, err := Natural(long, Options{Width: 2000, Height: 1 << 20, TextEngine: engine}) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | narrow, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine}) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | // A wrapped line may measure slightly over the limit (103.75 for 100 here); | |||
| 99 | + | // callers clamp to the limit, as Renderer.Measure does. | |||
| 100 | + | if narrow.W >= wide.W || narrow.H <= wide.H { | |||
| 101 | + | t.Fatalf("narrow=%+v wide=%+v", narrow, wide) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestNaturalNilAndHidden(t *testing.T) { | |||
| 106 | + | if got, err := Natural(nil, Options{}); err != nil || got != (Size{}) { | |||
| 107 | + | t.Fatalf("nil: %+v %v", got, err) | |||
| 108 | + | } | |||
| 109 | + | hidden := node("h", Box, css.Style{Display: css.KeywordNone}) | |||
| 110 | + | if got, err := Natural(hidden, Options{Width: 100}); err != nil || got != (Size{}) { | |||
| 111 | + | t.Fatalf("hidden: %+v %v", got, err) | |||
| 112 | + | } | |||
| 113 | + | } | |||