detached db490c8

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
218218 return out, nil
219219 }
220220
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+
221292 // display paints the placed tree into a list sized once from the tree.
222293 func (c context) display(root *Node, tree *Result) []Command {
223294 n := countCommands(root, tree)
@@ -218,6 +218,77 @@ func Layout(root *Node, options Options) (Output, error) {
218 return out, nil218 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+}

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