fix(layout): preserve natural flex sizes and empty editor line metrics
- commit
- 7ad3316fd1add28c1507e28c36883afaeaaad434
- author
- brice · · 2026-09-30 05:51 UTC
- parent
- a510003
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +216 -5
M examples/demo/app.css +2 -2
| @@ -50,8 +50,8 @@ button.menu-item.active { background: var(--selected); font-weight: 600; } | |||
| 50 | 50 | button.theme-toggle { width: 104px; font-size: 13px; } | |
| 51 | 51 | main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; } | |
| 52 | 52 | row.workspace { height: 100%; align-items: stretch; } | |
| 53 | - | column.editor-pane { flex-grow: 1; min-width: 0; background: var(--surface); } | |
| 54 | - | row.pane-bar { height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); } | |
| 53 | + | column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); } | |
| 54 | + | row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); } | |
| 55 | 55 | text.document-title { font-size: 13px; font-weight: 600; } | |
| 56 | 56 | textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; } | |
| 57 | 57 | aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); } | |
| @@ -50,8 +50,8 @@ button.menu-item.active { background: var(--selected); font-weight: 600; } | |||||
| 50 | button.theme-toggle { width: 104px; font-size: 13px; } | 50 | button.theme-toggle { width: 104px; font-size: 13px; } | ||
| 51 | main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; } | 51 | main.content { flex-grow: 1; min-width: 0; padding: 0; overflow: auto; } | ||
| 52 | row.workspace { height: 100%; align-items: stretch; } | 52 | row.workspace { height: 100%; align-items: stretch; } | ||
| 53 | - | column.editor-pane { flex-grow: 1; min-width: 0; background: var(--surface); } | 53 | + | column.editor-pane { flex-grow: 1; flex-basis: 0px; min-width: 0; background: var(--surface); } |
| 54 | - | row.pane-bar { height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); } | 54 | + | row.pane-bar { width: 100%; height: 36px; flex-shrink: 0; align-items: center; padding: 8px 16px; border-bottom: 1px solid var(--line); background: var(--canvas); } |
| 55 | text.document-title { font-size: 13px; font-weight: 600; } | 55 | text.document-title { font-size: 13px; font-weight: 600; } | ||
| 56 | textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; } | 56 | textarea.document-editor { flex-grow: 1; min-height: 0; padding: 20px; background: var(--surface); color: var(--ink); font-size: 14px; line-height: 1.6; } | ||
| 57 | aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); } | 57 | aside.inspector { display: flex; flex-direction: column; width: 240px; flex-shrink: 0; padding: 16px; border-left: 1px solid var(--line); background: var(--sidebar); } | ||
A internal/layout/intrinsic_container_test.go +56 -0
| @@ -0,0 +1,56 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestAutoFlexColumnReservesChildrenAndGaps(t *testing.T) { | |
| 10 | + | leaf := func(id string) *Node { | |
| 11 | + | return node(id, Box, css.Style{Display: css.KeywordBlock, Height: px(20), Width: px(80)}) | |
| 12 | + | } | |
| 13 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, RowGap: px(8)}, leaf("one"), leaf("two")) | |
| 14 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(200)}, column), Options{Width: 300, Height: 200}) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | got := out.Tree.Children[0] | |
| 19 | + | if got.Content.H != 48 || got.Children[1].Border.Y != 28 { | |
| 20 | + | t.Fatalf("column geometry: %+v", got) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestNestedFlexPercentWidthUsesNaturalSize(t *testing.T) { | |
| 25 | + | leaf := node("leaf", Box, css.Style{Display: css.KeywordBlock, Width: px(80), Height: px(24)}) | |
| 26 | + | percent := node("percent", Box, css.Style{Display: css.KeywordBlock, Width: css.Length{Value: 100, Unit: "%"}}, leaf) | |
| 27 | + | nested := node("nested", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, percent) | |
| 28 | + | out, err := Layout(node("root", Row, css.Style{Display: css.KeywordFlex, Width: px(300), Height: px(100)}, nested), Options{Width: 300, Height: 100}) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | if got := out.Tree.Children[0].Border.W; got != 80 { | |
| 33 | + | t.Fatalf("nested width=%g", got) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestAutoFlexHeightBoundsBeforeDistribution(t *testing.T) { | |
| 38 | + | for _, tc := range []struct { | |
| 39 | + | name string | |
| 40 | + | min, max css.Length | |
| 41 | + | child, want float64 | |
| 42 | + | }{{"minimum", px(200), css.Length{}, 0, 200}, {"maximum", css.Length{}, px(40), 100, 40}} { | |
| 43 | + | t.Run(tc.name, func(t *testing.T) { | |
| 44 | + | child := node("child", Box, css.Style{Display: css.KeywordBlock, Height: px(tc.child), FlexGrow: 1, FlexShrink: 1}) | |
| 45 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, MinHeight: tc.min, MaxHeight: tc.max}, child) | |
| 46 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(300)}, column), Options{Width: 300, Height: 300}) | |
| 47 | + | if err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | got := out.Tree.Children[0] | |
| 51 | + | if got.Border.H != tc.want || got.Children[0].Border.H != tc.want { | |
| 52 | + | t.Fatalf("container=%g child=%g want=%g", got.Border.H, got.Children[0].Border.H, tc.want) | |
| 53 | + | } | |
| 54 | + | }) | |
| 55 | + | } | |
| 56 | + | } | |
| @@ -0,0 +1,56 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestAutoFlexColumnReservesChildrenAndGaps(t *testing.T) { | |||
| 10 | + | leaf := func(id string) *Node { | |||
| 11 | + | return node(id, Box, css.Style{Display: css.KeywordBlock, Height: px(20), Width: px(80)}) | |||
| 12 | + | } | |||
| 13 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, RowGap: px(8)}, leaf("one"), leaf("two")) | |||
| 14 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(200)}, column), Options{Width: 300, Height: 200}) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatal(err) | |||
| 17 | + | } | |||
| 18 | + | got := out.Tree.Children[0] | |||
| 19 | + | if got.Content.H != 48 || got.Children[1].Border.Y != 28 { | |||
| 20 | + | t.Fatalf("column geometry: %+v", got) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestNestedFlexPercentWidthUsesNaturalSize(t *testing.T) { | |||
| 25 | + | leaf := node("leaf", Box, css.Style{Display: css.KeywordBlock, Width: px(80), Height: px(24)}) | |||
| 26 | + | percent := node("percent", Box, css.Style{Display: css.KeywordBlock, Width: css.Length{Value: 100, Unit: "%"}}, leaf) | |||
| 27 | + | nested := node("nested", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn}, percent) | |||
| 28 | + | out, err := Layout(node("root", Row, css.Style{Display: css.KeywordFlex, Width: px(300), Height: px(100)}, nested), Options{Width: 300, Height: 100}) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | if got := out.Tree.Children[0].Border.W; got != 80 { | |||
| 33 | + | t.Fatalf("nested width=%g", got) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestAutoFlexHeightBoundsBeforeDistribution(t *testing.T) { | |||
| 38 | + | for _, tc := range []struct { | |||
| 39 | + | name string | |||
| 40 | + | min, max css.Length | |||
| 41 | + | child, want float64 | |||
| 42 | + | }{{"minimum", px(200), css.Length{}, 0, 200}, {"maximum", css.Length{}, px(40), 100, 40}} { | |||
| 43 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 44 | + | child := node("child", Box, css.Style{Display: css.KeywordBlock, Height: px(tc.child), FlexGrow: 1, FlexShrink: 1}) | |||
| 45 | + | column := node("column", Column, css.Style{Display: css.KeywordFlex, FlexDirection: css.KeywordColumn, MinHeight: tc.min, MaxHeight: tc.max}, child) | |||
| 46 | + | out, err := Layout(node("root", Box, css.Style{Display: css.KeywordBlock, Width: px(300), Height: px(300)}, column), Options{Width: 300, Height: 300}) | |||
| 47 | + | if err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | got := out.Tree.Children[0] | |||
| 51 | + | if got.Border.H != tc.want || got.Children[0].Border.H != tc.want { | |||
| 52 | + | t.Fatalf("container=%g child=%g want=%g", got.Border.H, got.Children[0].Border.H, tc.want) | |||
| 53 | + | } | |||
| 54 | + | }) | |||
| 55 | + | } | |||
| 56 | + | } | |||
M internal/layout/layout.go +92 -3
| @@ -227,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord | |||
| 227 | 227 | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | |
| 228 | 228 | } | |
| 229 | 229 | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |
| 230 | + | return c.intrinsicDepth(n, w, 0) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) { | |
| 234 | + | if depth > 256 { | |
| 235 | + | return Size{}, nil, ErrDepth | |
| 236 | + | } | |
| 230 | 237 | if n.Measure != nil { | |
| 231 | 238 | s, lines, err := n.Measure(w) | |
| 232 | 239 | if err != nil { | |
| @@ -248,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||
| 248 | 255 | if req.Size == 0 { | |
| 249 | 256 | req.Size = 16 | |
| 250 | 257 | } | |
| 251 | - | l, err := c.options.TextEngine.Measure(n.Content, req, w) | |
| 258 | + | content := n.Content | |
| 259 | + | if content == "" && n.EditorScroll { | |
| 260 | + | // Preserve one font-metric line for empty editors, caret geometry | |
| 261 | + | // and IME composition without painting a visible glyph. | |
| 262 | + | content = " " | |
| 263 | + | } | |
| 264 | + | l, err := c.options.TextEngine.Measure(content, req, w) | |
| 252 | 265 | if err != nil { | |
| 253 | 266 | return Size{}, nil, err | |
| 254 | 267 | } | |
| @@ -282,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||
| 282 | 295 | } | |
| 283 | 296 | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | |
| 284 | 297 | } | |
| 285 | - | return Size{}, nil, nil | |
| 298 | + | if depth == 0 { | |
| 299 | + | return Size{}, nil, nil | |
| 300 | + | } | |
| 301 | + | // Containers contribute their children to auto flex sizing. Measuring them | |
| 302 | + | // as zero compresses nested columns and makes empty editors collapse. | |
| 303 | + | st := style(n) | |
| 304 | + | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | |
| 305 | + | gap := positive(st.RowGap, w) | |
| 306 | + | if row { | |
| 307 | + | gap = positive(st.ColumnGap, w) | |
| 308 | + | } | |
| 309 | + | var size Size | |
| 310 | + | count := 0 | |
| 311 | + | for _, child := range n.Children { | |
| 312 | + | if child == nil || style(child).Display == css.KeywordNone { | |
| 313 | + | continue | |
| 314 | + | } | |
| 315 | + | cs := style(child) | |
| 316 | + | inW, inH := flexInsets(cs, w) | |
| 317 | + | measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1) | |
| 318 | + | if err != nil { | |
| 319 | + | return Size{}, nil, err | |
| 320 | + | } | |
| 321 | + | cw, ch := measured.W+inW, measured.H+inH | |
| 322 | + | if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" { | |
| 323 | + | cw = value | |
| 324 | + | if cs.BoxSizing != css.KeywordBorderBox { | |
| 325 | + | cw += inW | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | // Percent heights need a definite containing height, unavailable here. | |
| 329 | + | if cs.Height.Unit != "%" { | |
| 330 | + | if value, ok := length(cs.Height, 0); ok { | |
| 331 | + | ch = value | |
| 332 | + | if cs.BoxSizing != css.KeywordBorderBox { | |
| 333 | + | ch += inH | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox) | |
| 338 | + | ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox) | |
| 339 | + | // Natural measurement is an unwrapped envelope; actual placement | |
| 340 | + | // distributes flex-basis and wraps against the definite container size. | |
| 341 | + | margin := edges(cs.Margin, w) | |
| 342 | + | cw += margin.horizontal() | |
| 343 | + | ch += margin.vertical() | |
| 344 | + | if row { | |
| 345 | + | if count > 0 { | |
| 346 | + | size.W += gap | |
| 347 | + | } | |
| 348 | + | size.W += cw | |
| 349 | + | size.H = math.Max(size.H, ch) | |
| 350 | + | } else if n.Kind == Stack { | |
| 351 | + | size.W = math.Max(size.W, cw) | |
| 352 | + | size.H = math.Max(size.H, ch) | |
| 353 | + | } else { | |
| 354 | + | if count > 0 && st.Display == css.KeywordFlex { | |
| 355 | + | size.H += gap | |
| 356 | + | } | |
| 357 | + | size.W = math.Max(size.W, cw) | |
| 358 | + | size.H += ch | |
| 359 | + | } | |
| 360 | + | count++ | |
| 361 | + | } | |
| 362 | + | return size, nil, nil | |
| 286 | 363 | } | |
| 287 | 364 | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | |
| 288 | 365 | if depth > 256 { | |
| @@ -382,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f | |||
| 382 | 459 | // Block and stack auto heights depend on their children's envelopes. | |
| 383 | 460 | flex := s.Display == css.KeywordFlex | |
| 384 | 461 | if flex { | |
| 462 | + | // An auto-height flex container needs its children's natural height | |
| 463 | + | // before distributing space. Otherwise every child is shrunk into zero. | |
| 464 | + | // This repeats subtree measurement for nested auto-height containers; | |
| 465 | + | // a node/width map cache cost more time and memory in our benchmarks. | |
| 466 | + | if !explicitH { | |
| 467 | + | natural, _, measureErr := c.intrinsicDepth(n, contentW, 1) | |
| 468 | + | if measureErr != nil { | |
| 469 | + | return nil, measureErr | |
| 470 | + | } | |
| 471 | + | r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |
| 472 | + | r.Content.H = safe(r.Border.H - insetH) | |
| 473 | + | } | |
| 385 | 474 | err = c.flex(n, r, s, depth) | |
| 386 | 475 | } else { | |
| 387 | 476 | err = c.children(n, r, s, depth) | |
| @@ -627,7 +716,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||
| 627 | 716 | } else { | |
| 628 | 717 | measureWidth = crossSize | |
| 629 | 718 | } | |
| 630 | - | measured, _, err := c.intrinsic(ch, measureWidth) | |
| 719 | + | measured, _, err := c.intrinsicDepth(ch, measureWidth, 1) | |
| 631 | 720 | if err != nil { | |
| 632 | 721 | return err | |
| 633 | 722 | } | |
| @@ -227,6 +227,13 @@ func boxBound(border float64, minL, maxL css.Length, basis, insets float64, bord | |||||
| 227 | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | 227 | return safe(bounded(safe(border-insets), minL, maxL, basis) + insets) | ||
| 228 | } | 228 | } | ||
| 229 | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | 229 | func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | ||
| 230 | + | return c.intrinsicDepth(n, w, 0) | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Line, error) { | |||
| 234 | + | if depth > 256 { | |||
| 235 | + | return Size{}, nil, ErrDepth | |||
| 236 | + | } | |||
| 230 | if n.Measure != nil { | 237 | if n.Measure != nil { | ||
| 231 | s, lines, err := n.Measure(w) | 238 | s, lines, err := n.Measure(w) | ||
| 232 | if err != nil { | 239 | if err != nil { | ||
| @@ -248,7 +255,13 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||||
| 248 | if req.Size == 0 { | 255 | if req.Size == 0 { | ||
| 249 | req.Size = 16 | 256 | req.Size = 16 | ||
| 250 | } | 257 | } | ||
| 251 | - | l, err := c.options.TextEngine.Measure(n.Content, req, w) | 258 | + | content := n.Content |
| 259 | + | if content == "" && n.EditorScroll { | |||
| 260 | + | // Preserve one font-metric line for empty editors, caret geometry | |||
| 261 | + | // and IME composition without painting a visible glyph. | |||
| 262 | + | content = " " | |||
| 263 | + | } | |||
| 264 | + | l, err := c.options.TextEngine.Measure(content, req, w) | |||
| 252 | if err != nil { | 265 | if err != nil { | ||
| 253 | return Size{}, nil, err | 266 | return Size{}, nil, err | ||
| 254 | } | 267 | } | ||
| @@ -282,7 +295,71 @@ func (c context) intrinsic(n *Node, w float64) (Size, []text.Line, error) { | |||||
| 282 | } | 295 | } | ||
| 283 | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | 296 | return Size{safe(l.Width), safe(l.Height)}, l.Lines, nil | ||
| 284 | } | 297 | } | ||
| 285 | - | return Size{}, nil, nil | 298 | + | if depth == 0 { |
| 299 | + | return Size{}, nil, nil | |||
| 300 | + | } | |||
| 301 | + | // Containers contribute their children to auto flex sizing. Measuring them | |||
| 302 | + | // as zero compresses nested columns and makes empty editors collapse. | |||
| 303 | + | st := style(n) | |||
| 304 | + | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | |||
| 305 | + | gap := positive(st.RowGap, w) | |||
| 306 | + | if row { | |||
| 307 | + | gap = positive(st.ColumnGap, w) | |||
| 308 | + | } | |||
| 309 | + | var size Size | |||
| 310 | + | count := 0 | |||
| 311 | + | for _, child := range n.Children { | |||
| 312 | + | if child == nil || style(child).Display == css.KeywordNone { | |||
| 313 | + | continue | |||
| 314 | + | } | |||
| 315 | + | cs := style(child) | |||
| 316 | + | inW, inH := flexInsets(cs, w) | |||
| 317 | + | measured, _, err := c.intrinsicDepth(child, safe(w-inW), depth+1) | |||
| 318 | + | if err != nil { | |||
| 319 | + | return Size{}, nil, err | |||
| 320 | + | } | |||
| 321 | + | cw, ch := measured.W+inW, measured.H+inH | |||
| 322 | + | if value, ok := length(cs.Width, w); ok && cs.Width.Unit != "%" { | |||
| 323 | + | cw = value | |||
| 324 | + | if cs.BoxSizing != css.KeywordBorderBox { | |||
| 325 | + | cw += inW | |||
| 326 | + | } | |||
| 327 | + | } | |||
| 328 | + | // Percent heights need a definite containing height, unavailable here. | |||
| 329 | + | if cs.Height.Unit != "%" { | |||
| 330 | + | if value, ok := length(cs.Height, 0); ok { | |||
| 331 | + | ch = value | |||
| 332 | + | if cs.BoxSizing != css.KeywordBorderBox { | |||
| 333 | + | ch += inH | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | cw = boxBound(cw, cs.MinWidth, cs.MaxWidth, w, inW, cs.BoxSizing == css.KeywordBorderBox) | |||
| 338 | + | ch = boxBound(ch, cs.MinHeight, cs.MaxHeight, 0, inH, cs.BoxSizing == css.KeywordBorderBox) | |||
| 339 | + | // Natural measurement is an unwrapped envelope; actual placement | |||
| 340 | + | // distributes flex-basis and wraps against the definite container size. | |||
| 341 | + | margin := edges(cs.Margin, w) | |||
| 342 | + | cw += margin.horizontal() | |||
| 343 | + | ch += margin.vertical() | |||
| 344 | + | if row { | |||
| 345 | + | if count > 0 { | |||
| 346 | + | size.W += gap | |||
| 347 | + | } | |||
| 348 | + | size.W += cw | |||
| 349 | + | size.H = math.Max(size.H, ch) | |||
| 350 | + | } else if n.Kind == Stack { | |||
| 351 | + | size.W = math.Max(size.W, cw) | |||
| 352 | + | size.H = math.Max(size.H, ch) | |||
| 353 | + | } else { | |||
| 354 | + | if count > 0 && st.Display == css.KeywordFlex { | |||
| 355 | + | size.H += gap | |||
| 356 | + | } | |||
| 357 | + | size.W = math.Max(size.W, cw) | |||
| 358 | + | size.H += ch | |||
| 359 | + | } | |||
| 360 | + | count++ | |||
| 361 | + | } | |||
| 362 | + | return size, nil, nil | |||
| 286 | } | 363 | } | ||
| 287 | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | 364 | func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, forcedH *float64, depth int) (*Result, error) { | ||
| 288 | if depth > 256 { | 365 | if depth > 256 { | ||
| @@ -382,6 +459,18 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f | |||||
| 382 | // Block and stack auto heights depend on their children's envelopes. | 459 | // Block and stack auto heights depend on their children's envelopes. | ||
| 383 | flex := s.Display == css.KeywordFlex | 460 | flex := s.Display == css.KeywordFlex | ||
| 384 | if flex { | 461 | if flex { | ||
| 462 | + | // An auto-height flex container needs its children's natural height | |||
| 463 | + | // before distributing space. Otherwise every child is shrunk into zero. | |||
| 464 | + | // This repeats subtree measurement for nested auto-height containers; | |||
| 465 | + | // a node/width map cache cost more time and memory in our benchmarks. | |||
| 466 | + | if !explicitH { | |||
| 467 | + | natural, _, measureErr := c.intrinsicDepth(n, contentW, 1) | |||
| 468 | + | if measureErr != nil { | |||
| 469 | + | return nil, measureErr | |||
| 470 | + | } | |||
| 471 | + | r.Border.H = boxBound(math.Max(natural.H+insetH, h), s.MinHeight, s.MaxHeight, availableH, insetH, borderBox) | |||
| 472 | + | r.Content.H = safe(r.Border.H - insetH) | |||
| 473 | + | } | |||
| 385 | err = c.flex(n, r, s, depth) | 474 | err = c.flex(n, r, s, depth) | ||
| 386 | } else { | 475 | } else { | ||
| 387 | err = c.children(n, r, s, depth) | 476 | err = c.children(n, r, s, depth) | ||
| @@ -627,7 +716,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||||
| 627 | } else { | 716 | } else { | ||
| 628 | measureWidth = crossSize | 717 | measureWidth = crossSize | ||
| 629 | } | 718 | } | ||
| 630 | - | measured, _, err := c.intrinsic(ch, measureWidth) | 719 | + | measured, _, err := c.intrinsicDepth(ch, measureWidth, 1) |
| 631 | if err != nil { | 720 | if err != nil { | ||
| 632 | return err | 721 | return err | ||
| 633 | } | 722 | } | ||
A internal/ui/empty_caret_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package ui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/text" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestEmptyLabelsDoNotReserveEditorLine(t *testing.T) { | |
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | r := newRuntime() | |
| 17 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 18 | + | r.Build(func(f *Frame) { | |
| 19 | + | root := f.Root() | |
| 20 | + | root.Text("") | |
| 21 | + | root.Heading("") | |
| 22 | + | }) | |
| 23 | + | for _, child := range r.output.Tree.Children { | |
| 24 | + | if child.Content.H != 0 || len(child.Lines) != 0 { | |
| 25 | + | t.Fatalf("empty label reserves line: %+v", child) | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestEmptyTextareaCaretUsesLineHeight(t *testing.T) { | |
| 31 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | for _, height := range []string{"normal", "1.5", "30px", "1.5em", "150%"} { | |
| 36 | + | t.Run(height, func(t *testing.T) { | |
| 37 | + | r := newRuntime() | |
| 38 | + | r.setTextEngine(text.NewEngine(catalog)) | |
| 39 | + | r.styles = css.Compile(css.UA(), css.Parse(fmt.Sprintf(`textarea {height:400px;width:300px;font-size:20px;line-height:%s}`, height))) | |
| 40 | + | value := "" | |
| 41 | + | view := func(f *Frame) { f.Root().Textarea("Document", &value) } | |
| 42 | + | r.Build(view) | |
| 43 | + | r.route(key("Tab")) | |
| 44 | + | r.Build(view) | |
| 45 | + | caretHeight := func() float64 { | |
| 46 | + | for _, cmd := range r.output.Display { | |
| 47 | + | if cmd.Op == "caret" { | |
| 48 | + | return cmd.Rect.H | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | t.Fatal("missing caret") | |
| 52 | + | return 0 | |
| 53 | + | } | |
| 54 | + | empty := caretHeight() | |
| 55 | + | if empty <= 0 || empty >= 100 { | |
| 56 | + | t.Fatalf("empty caret height=%g", empty) | |
| 57 | + | } | |
| 58 | + | value = "x" | |
| 59 | + | r.Redraw() | |
| 60 | + | r.Build(view) | |
| 61 | + | if got := caretHeight(); got != empty { | |
| 62 | + | t.Fatalf("empty=%g populated=%g", empty, got) | |
| 63 | + | } | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | } | |
| @@ -0,0 +1,66 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | "github.com/bnema/nefergui/internal/text" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestEmptyLabelsDoNotReserveEditorLine(t *testing.T) { | |||
| 12 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | r := newRuntime() | |||
| 17 | + | r.setTextEngine(text.NewEngine(catalog)) | |||
| 18 | + | r.Build(func(f *Frame) { | |||
| 19 | + | root := f.Root() | |||
| 20 | + | root.Text("") | |||
| 21 | + | root.Heading("") | |||
| 22 | + | }) | |||
| 23 | + | for _, child := range r.output.Tree.Children { | |||
| 24 | + | if child.Content.H != 0 || len(child.Lines) != 0 { | |||
| 25 | + | t.Fatalf("empty label reserves line: %+v", child) | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestEmptyTextareaCaretUsesLineHeight(t *testing.T) { | |||
| 31 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | for _, height := range []string{"normal", "1.5", "30px", "1.5em", "150%"} { | |||
| 36 | + | t.Run(height, func(t *testing.T) { | |||
| 37 | + | r := newRuntime() | |||
| 38 | + | r.setTextEngine(text.NewEngine(catalog)) | |||
| 39 | + | r.styles = css.Compile(css.UA(), css.Parse(fmt.Sprintf(`textarea {height:400px;width:300px;font-size:20px;line-height:%s}`, height))) | |||
| 40 | + | value := "" | |||
| 41 | + | view := func(f *Frame) { f.Root().Textarea("Document", &value) } | |||
| 42 | + | r.Build(view) | |||
| 43 | + | r.route(key("Tab")) | |||
| 44 | + | r.Build(view) | |||
| 45 | + | caretHeight := func() float64 { | |||
| 46 | + | for _, cmd := range r.output.Display { | |||
| 47 | + | if cmd.Op == "caret" { | |||
| 48 | + | return cmd.Rect.H | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | t.Fatal("missing caret") | |||
| 52 | + | return 0 | |||
| 53 | + | } | |||
| 54 | + | empty := caretHeight() | |||
| 55 | + | if empty <= 0 || empty >= 100 { | |||
| 56 | + | t.Fatalf("empty caret height=%g", empty) | |||
| 57 | + | } | |||
| 58 | + | value = "x" | |||
| 59 | + | r.Redraw() | |||
| 60 | + | r.Build(view) | |||
| 61 | + | if got := caretHeight(); got != empty { | |||
| 62 | + | t.Fatalf("empty=%g populated=%g", empty, got) | |||
| 63 | + | } | |||
| 64 | + | }) | |||
| 65 | + | } | |||
| 66 | + | } | |||