detached cb70a9a

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
221221 // Natural returns the border-box size root takes when it is not stretched: the
222222 // widest and tallest envelope of its content plus its padding and border,
223223 // 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.
226230 func Natural(root *Node, options Options) (Size, error) {
227231 if root == nil {
228232 return Size{}, nil
@@ -235,6 +239,7 @@ func Natural(root *Node, options Options) (Size, error) {
235239 if err := validateStackRects(root, s); err != nil {
236240 return Size{}, err
237241 }
242+ s = IndefiniteHeights(s)
238243 availableW, availableH := safe(options.Width), safe(options.Height)
239244 margin := edges(s.Margin, availableW)
240245 padding := nonnegative(edges(s.Padding, availableW))
@@ -289,6 +294,22 @@ func Natural(root *Node, options Options) (Size, error) {
289294 return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil
290295 }
291296
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+
292313 // display paints the placed tree into a list sized once from the tree.
293314 func (c context) display(root *Node, tree *Result) []Command {
294315 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: the221 // 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 at223 // 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 says224+// 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{}, nil232 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{}, err240 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))}, nil294 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 @@
11 package layout
22
33 import (
4+ "errors"
45 "testing"
56
67 "github.com/bnema/nefergui/internal/css"
@@ -51,8 +52,14 @@ func TestNaturalRowSumsWithGap(t *testing.T) {
5152 engine := naturalEngine(t)
5253 a, b := textNode("a", "alpha"), textNode("b", "beta")
5354 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+ }
5663 row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b)
5764 got, err := Natural(row, opts)
5865 if err != nil {
@@ -111,3 +118,86 @@ func TestNaturalNilAndHidden(t *testing.T) {
111118 t.Fatalf("hidden: %+v %v", got, err)
112119 }
113120 }
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 layout1 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+}

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