fix(layout): reject Rect children in flex-styled Stacks
Diagnose visible Rect children consistently during intrinsic measurement and placement instead of silently ignoring their geometry. Preserve ordinary Stack Rect handling.
- commit
- 75780d042523a9fb930ed0e7d8be5472a4af0dc7
- author
- brice · · 2026-09-30 14:38 UTC
- committer
- brice · · 2026-09-30 14:46 UTC
- parent
- 1032255
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +40 -1
M internal/layout/layout.go +18 -1
| @@ -108,6 +108,7 @@ type Node struct { | |||
| 108 | 108 | // Rect, when HasRect is set on a direct child of a Stack, is the child's | |
| 109 | 109 | // border-box geometry relative to the Stack's content box. It replaces the | |
| 110 | 110 | // child's authored width, height and margin; it is ignored elsewhere. | |
| 111 | + | // A flex-styled Stack with a visible Rect child is rejected. | |
| 111 | 112 | Rect Rect | |
| 112 | 113 | HasRect bool | |
| 113 | 114 | } | |
| @@ -213,6 +214,16 @@ func style(n *Node) css.Style { | |||
| 213 | 214 | } | |
| 214 | 215 | return css.Style{Display: css.KeywordBlock, OverflowX: css.KeywordVisible, OverflowY: css.KeywordVisible, FlexShrink: 1} | |
| 215 | 216 | } | |
| 217 | + | func validateStackRects(n *Node, s css.Style) error { | |
| 218 | + | if n.Kind == Stack && s.Display == css.KeywordFlex { | |
| 219 | + | for _, child := range n.Children { | |
| 220 | + | if child != nil && child.HasRect && style(child).Display != css.KeywordNone { | |
| 221 | + | return errors.New("layout: Rect is unsupported in a flex-styled Stack") | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | return nil | |
| 226 | + | } | |
| 216 | 227 | func bounded(v float64, minL, maxL css.Length, basis float64) float64 { | |
| 217 | 228 | v = safe(v) | |
| 218 | 229 | if min, ok := length(minL, basis); ok { | |
| @@ -239,6 +250,10 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin | |||
| 239 | 250 | if depth > 256 { | |
| 240 | 251 | return Size{}, nil, ErrDepth | |
| 241 | 252 | } | |
| 253 | + | st := style(n) | |
| 254 | + | if err := validateStackRects(n, st); err != nil { | |
| 255 | + | return Size{}, nil, err | |
| 256 | + | } | |
| 242 | 257 | if n.Measure != nil { | |
| 243 | 258 | s, lines, err := n.Measure(w) | |
| 244 | 259 | if err != nil { | |
| @@ -305,7 +320,6 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin | |||
| 305 | 320 | } | |
| 306 | 321 | // Containers contribute their children to auto flex sizing. Measuring them | |
| 307 | 322 | // as zero compresses nested columns and makes empty editors collapse. | |
| 308 | - | st := style(n) | |
| 309 | 323 | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | |
| 310 | 324 | gap := positive(st.RowGap, w) | |
| 311 | 325 | if row { | |
| @@ -383,6 +397,9 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f | |||
| 383 | 397 | if s.Display == css.KeywordNone { | |
| 384 | 398 | return nil, nil | |
| 385 | 399 | } | |
| 400 | + | if err := validateStackRects(n, s); err != nil { | |
| 401 | + | return nil, err | |
| 402 | + | } | |
| 386 | 403 | availableW = safe(availableW) | |
| 387 | 404 | availableH = safe(availableH) | |
| 388 | 405 | margin := edges(s.Margin, availableW) | |
| @@ -108,6 +108,7 @@ type Node struct { | |||||
| 108 | // Rect, when HasRect is set on a direct child of a Stack, is the child's | 108 | // Rect, when HasRect is set on a direct child of a Stack, is the child's | ||
| 109 | // border-box geometry relative to the Stack's content box. It replaces the | 109 | // border-box geometry relative to the Stack's content box. It replaces the | ||
| 110 | // child's authored width, height and margin; it is ignored elsewhere. | 110 | // child's authored width, height and margin; it is ignored elsewhere. | ||
| 111 | + | // A flex-styled Stack with a visible Rect child is rejected. | |||
| 111 | Rect Rect | 112 | Rect Rect | ||
| 112 | HasRect bool | 113 | HasRect bool | ||
| 113 | } | 114 | } | ||
| @@ -213,6 +214,16 @@ func style(n *Node) css.Style { | |||||
| 213 | } | 214 | } | ||
| 214 | return css.Style{Display: css.KeywordBlock, OverflowX: css.KeywordVisible, OverflowY: css.KeywordVisible, FlexShrink: 1} | 215 | return css.Style{Display: css.KeywordBlock, OverflowX: css.KeywordVisible, OverflowY: css.KeywordVisible, FlexShrink: 1} | ||
| 215 | } | 216 | } | ||
| 217 | + | func validateStackRects(n *Node, s css.Style) error { | |||
| 218 | + | if n.Kind == Stack && s.Display == css.KeywordFlex { | |||
| 219 | + | for _, child := range n.Children { | |||
| 220 | + | if child != nil && child.HasRect && style(child).Display != css.KeywordNone { | |||
| 221 | + | return errors.New("layout: Rect is unsupported in a flex-styled Stack") | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | return nil | |||
| 226 | + | } | |||
| 216 | func bounded(v float64, minL, maxL css.Length, basis float64) float64 { | 227 | func bounded(v float64, minL, maxL css.Length, basis float64) float64 { | ||
| 217 | v = safe(v) | 228 | v = safe(v) | ||
| 218 | if min, ok := length(minL, basis); ok { | 229 | if min, ok := length(minL, basis); ok { | ||
| @@ -239,6 +250,10 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin | |||||
| 239 | if depth > 256 { | 250 | if depth > 256 { | ||
| 240 | return Size{}, nil, ErrDepth | 251 | return Size{}, nil, ErrDepth | ||
| 241 | } | 252 | } | ||
| 253 | + | st := style(n) | |||
| 254 | + | if err := validateStackRects(n, st); err != nil { | |||
| 255 | + | return Size{}, nil, err | |||
| 256 | + | } | |||
| 242 | if n.Measure != nil { | 257 | if n.Measure != nil { | ||
| 243 | s, lines, err := n.Measure(w) | 258 | s, lines, err := n.Measure(w) | ||
| 244 | if err != nil { | 259 | if err != nil { | ||
| @@ -305,7 +320,6 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin | |||||
| 305 | } | 320 | } | ||
| 306 | // Containers contribute their children to auto flex sizing. Measuring them | 321 | // Containers contribute their children to auto flex sizing. Measuring them | ||
| 307 | // as zero compresses nested columns and makes empty editors collapse. | 322 | // as zero compresses nested columns and makes empty editors collapse. | ||
| 308 | - | st := style(n) | |||
| 309 | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | 323 | row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn | ||
| 310 | gap := positive(st.RowGap, w) | 324 | gap := positive(st.RowGap, w) | ||
| 311 | if row { | 325 | if row { | ||
| @@ -383,6 +397,9 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f | |||||
| 383 | if s.Display == css.KeywordNone { | 397 | if s.Display == css.KeywordNone { | ||
| 384 | return nil, nil | 398 | return nil, nil | ||
| 385 | } | 399 | } | ||
| 400 | + | if err := validateStackRects(n, s); err != nil { | |||
| 401 | + | return nil, err | |||
| 402 | + | } | |||
| 386 | availableW = safe(availableW) | 403 | availableW = safe(availableW) | ||
| 387 | availableH = safe(availableH) | 404 | availableH = safe(availableH) | ||
| 388 | margin := edges(s.Margin, availableW) | 405 | margin := edges(s.Margin, availableW) | ||
M internal/layout/layout_test.go +22 -0
| @@ -544,6 +544,28 @@ func TestDirectionalTextAlignment(t *testing.T) { | |||
| 544 | 544 | } | |
| 545 | 545 | } | |
| 546 | 546 | ||
| 547 | + | func TestFlexStackRejectsRect(t *testing.T) { | |
| 548 | + | for _, direction := range []css.Keyword{css.KeywordRow, css.KeywordColumn} { | |
| 549 | + | for _, nested := range []bool{false, true} { | |
| 550 | + | child := node("rect", Box, css.Style{Display: css.KeywordBlock}) | |
| 551 | + | child.Rect, child.HasRect = Rect{X: 10, Y: 20, W: 30, H: 40}, true | |
| 552 | + | stack := node("stack", Stack, css.Style{Display: css.KeywordFlex, FlexDirection: direction}, child) | |
| 553 | + | root := stack | |
| 554 | + | if nested { | |
| 555 | + | root = node("root", Row, css.Style{Display: css.KeywordFlex}, stack) | |
| 556 | + | } | |
| 557 | + | if _, err := Layout(root, Options{Width: 200, Height: 100}); err == nil || err.Error() != "layout: Rect is unsupported in a flex-styled Stack" { | |
| 558 | + | t.Fatalf("direction=%v nested=%v: err=%v", direction, nested, err) | |
| 559 | + | } | |
| 560 | + | // Hidden children do not participate in layout or validation. | |
| 561 | + | child.Style.Display = css.KeywordNone | |
| 562 | + | if _, err := Layout(root, Options{Width: 200, Height: 100}); err != nil { | |
| 563 | + | t.Fatalf("hidden Rect: %v", err) | |
| 564 | + | } | |
| 565 | + | } | |
| 566 | + | } | |
| 567 | + | } | |
| 568 | + | ||
| 547 | 569 | func TestStackAbsoluteRect(t *testing.T) { | |
| 548 | 570 | margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)} | |
| 549 | 571 | a := node("a", Box, margin) | |
| @@ -544,6 +544,28 @@ func TestDirectionalTextAlignment(t *testing.T) { | |||||
| 544 | } | 544 | } | ||
| 545 | } | 545 | } | ||
| 546 | 546 | ||||
| 547 | + | func TestFlexStackRejectsRect(t *testing.T) { | |||
| 548 | + | for _, direction := range []css.Keyword{css.KeywordRow, css.KeywordColumn} { | |||
| 549 | + | for _, nested := range []bool{false, true} { | |||
| 550 | + | child := node("rect", Box, css.Style{Display: css.KeywordBlock}) | |||
| 551 | + | child.Rect, child.HasRect = Rect{X: 10, Y: 20, W: 30, H: 40}, true | |||
| 552 | + | stack := node("stack", Stack, css.Style{Display: css.KeywordFlex, FlexDirection: direction}, child) | |||
| 553 | + | root := stack | |||
| 554 | + | if nested { | |||
| 555 | + | root = node("root", Row, css.Style{Display: css.KeywordFlex}, stack) | |||
| 556 | + | } | |||
| 557 | + | if _, err := Layout(root, Options{Width: 200, Height: 100}); err == nil || err.Error() != "layout: Rect is unsupported in a flex-styled Stack" { | |||
| 558 | + | t.Fatalf("direction=%v nested=%v: err=%v", direction, nested, err) | |||
| 559 | + | } | |||
| 560 | + | // Hidden children do not participate in layout or validation. | |||
| 561 | + | child.Style.Display = css.KeywordNone | |||
| 562 | + | if _, err := Layout(root, Options{Width: 200, Height: 100}); err != nil { | |||
| 563 | + | t.Fatalf("hidden Rect: %v", err) | |||
| 564 | + | } | |||
| 565 | + | } | |||
| 566 | + | } | |||
| 567 | + | } | |||
| 568 | + | ||||
| 547 | func TestStackAbsoluteRect(t *testing.T) { | 569 | func TestStackAbsoluteRect(t *testing.T) { | ||
| 548 | margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)} | 570 | margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)} | ||
| 549 | a := node("a", Box, margin) | 571 | a := node("a", Box, margin) | ||