detached 75780d0

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
108108 // Rect, when HasRect is set on a direct child of a Stack, is the child's
109109 // border-box geometry relative to the Stack's content box. It replaces the
110110 // child's authored width, height and margin; it is ignored elsewhere.
111+ // A flex-styled Stack with a visible Rect child is rejected.
111112 Rect Rect
112113 HasRect bool
113114 }
@@ -213,6 +214,16 @@ func style(n *Node) css.Style {
213214 }
214215 return css.Style{Display: css.KeywordBlock, OverflowX: css.KeywordVisible, OverflowY: css.KeywordVisible, FlexShrink: 1}
215216 }
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+}
216227 func bounded(v float64, minL, maxL css.Length, basis float64) float64 {
217228 v = safe(v)
218229 if min, ok := length(minL, basis); ok {
@@ -239,6 +250,10 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin
239250 if depth > 256 {
240251 return Size{}, nil, ErrDepth
241252 }
253+ st := style(n)
254+ if err := validateStackRects(n, st); err != nil {
255+ return Size{}, nil, err
256+ }
242257 if n.Measure != nil {
243258 s, lines, err := n.Measure(w)
244259 if err != nil {
@@ -305,7 +320,6 @@ func (c context) intrinsicDepth(n *Node, w float64, depth int) (Size, []text.Lin
305320 }
306321 // Containers contribute their children to auto flex sizing. Measuring them
307322 // as zero compresses nested columns and makes empty editors collapse.
308- st := style(n)
309323 row := st.Display == css.KeywordFlex && st.FlexDirection != css.KeywordColumn
310324 gap := positive(st.RowGap, w)
311325 if row {
@@ -383,6 +397,9 @@ func (c context) place(n *Node, x, y, availableW, availableH float64, forcedW, f
383397 if s.Display == css.KeywordNone {
384398 return nil, nil
385399 }
400+ if err := validateStackRects(n, s); err != nil {
401+ return nil, err
402+ }
386403 availableW = safe(availableW)
387404 availableH = safe(availableH)
388405 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's108 // 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 the109 // 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 Rect112 Rect Rect
112 HasRect bool113 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, ErrDepth251 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 them321 // 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.KeywordColumn323 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, nil398 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) {
544544 }
545545 }
546546
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+
547569 func TestStackAbsoluteRect(t *testing.T) {
548570 margin := css.Style{Display: css.KeywordBlock, Margin: css.LengthSides{Top: px(7), Left: px(9)}, Width: px(500), Height: px(500)}
549571 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close