detached 438604c

Merge pull request #1: feat: Renderer.Measure

commit
438604cef4a3e7d76c5dc3af03ccc2a8b1f2dc21
author
bnema · · 2026-10-09 04:11 UTC
merge of
aa12db3 + e225d7c · diff against the first parent
signature
unsigned
refs
tag: v0.7.0
8 files · +700 -5
M .github/workflows/ci.yml +7 -2
@@ -25,8 +25,13 @@ jobs:
2525 persist-credentials: false
2626 - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
2727 - run: |
28- go install github.com/vektra/mockery/v3@v3.8.0
29- go install honnef.co/go/tools/cmd/staticcheck@latest
28+ # Build the tools against the latest x/tools so they can read the
29+ # export data of the Go release installed above.
30+ cd "$(mktemp -d)"
31+ go mod init tools
32+ go get -tool github.com/vektra/mockery/v3@v3.8.0 honnef.co/go/tools/cmd/staticcheck@latest
33+ go get golang.org/x/tools@latest
34+ go install tool
3035 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
3136 - run: make check
3237 - run: make race
@@ -25,8 +25,13 @@ jobs:
25 persist-credentials: false25 persist-credentials: false
26 - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"26 - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
27 - run: |27 - run: |
28- go install github.com/vektra/mockery/v3@v3.8.028+ # Build the tools against the latest x/tools so they can read the
29- go install honnef.co/go/tools/cmd/staticcheck@latest29+ # export data of the Go release installed above.
30+ cd "$(mktemp -d)"
31+ go mod init tools
32+ go get -tool github.com/vektra/mockery/v3@v3.8.0 honnef.co/go/tools/cmd/staticcheck@latest
33+ go get golang.org/x/tools@latest
34+ go install tool
30 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"35 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
31 - run: make check36 - run: make check
32 - run: make race37 - run: make race
M docs/runtime.md +1 -0
@@ -7,6 +7,7 @@ A client loop forwards events and presents frames:
77 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.
88 - `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
99 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.
10+- `Measure(&model, view, maxWidth)` returns the natural `width` (at most `maxWidth`) and `height` of the view in logical pixels, rounded up, with text wrapped at `maxWidth`. The size is that of the root's margin box. Use it to size a surface, such as a popup, before creating it. It is a generic method like `Render`. The view is built in a temporary runtime that shares only the style sheet and text engine, so hover, focus, scroll offsets, editors, queued input, `Wake` and the target are untouched, nothing is drawn, and the view receives no events. The shared caches only grow during `Measure` (it does not age them), so a later `Render` is not slowed. There is no surface yet, so the height is indefinite: `Frame.Size` reports an unbounded height (`1<<20`) inside the view, and percentage `height`, `min-height` and `max-height` of the root are treated as `auto` (as for nested boxes) rather than resolved against that value; pixel heights apply. It allocates and shapes text: call it from the owner goroutine when opening a surface, not on every frame and never from inside a view callback. It works before `Resize` and returns an error for a closed Renderer, a nil model or view, a `maxWidth` that is not finite and above zero, or a view that cannot be laid out (the layout error is wrapped).
1011 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
1112 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.
1213 - `Close` cancels pending pastes and frees GPU resources.
@@ -7,6 +7,7 @@ A client loop forwards events and presents frames:
7 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.7 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.
8 - `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.8 - `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
9 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.9 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.
10+- `Measure(&model, view, maxWidth)` returns the natural `width` (at most `maxWidth`) and `height` of the view in logical pixels, rounded up, with text wrapped at `maxWidth`. The size is that of the root's margin box. Use it to size a surface, such as a popup, before creating it. It is a generic method like `Render`. The view is built in a temporary runtime that shares only the style sheet and text engine, so hover, focus, scroll offsets, editors, queued input, `Wake` and the target are untouched, nothing is drawn, and the view receives no events. The shared caches only grow during `Measure` (it does not age them), so a later `Render` is not slowed. There is no surface yet, so the height is indefinite: `Frame.Size` reports an unbounded height (`1<<20`) inside the view, and percentage `height`, `min-height` and `max-height` of the root are treated as `auto` (as for nested boxes) rather than resolved against that value; pixel heights apply. It allocates and shapes text: call it from the owner goroutine when opening a surface, not on every frame and never from inside a view callback. It works before `Resize` and returns an error for a closed Renderer, a nil model or view, a `maxWidth` that is not finite and above zero, or a view that cannot be laid out (the layout error is wrapped).
10 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.11 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
11 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.12 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.
12 - `Close` cancels pending pastes and frees GPU resources.13 - `Close` cancels pending pastes and frees GPU resources.
M internal/layout/layout.go +92 -0
@@ -218,6 +218,98 @@ 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 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.
230+func Natural(root *Node, options Options) (Size, error) {
231+ if root == nil {
232+ return Size{}, nil
233+ }
234+ ctx := newContext(options)
235+ s := style(root)
236+ if s.Display == css.KeywordNone {
237+ return Size{}, nil
238+ }
239+ if err := validateStackRects(root, s); err != nil {
240+ return Size{}, err
241+ }
242+ s = IndefiniteHeights(s)
243+ availableW, availableH := safe(options.Width), safe(options.Height)
244+ margin := edges(s.Margin, availableW)
245+ padding := nonnegative(edges(s.Padding, availableW))
246+ border := nonnegative(edges(s.BorderWidth, availableW))
247+ if s.BorderStyle.Top != css.KeywordSolid {
248+ border.Top = 0
249+ }
250+ if s.BorderStyle.Right != css.KeywordSolid {
251+ border.Right = 0
252+ }
253+ if s.BorderStyle.Bottom != css.KeywordSolid {
254+ border.Bottom = 0
255+ }
256+ if s.BorderStyle.Left != css.KeywordSolid {
257+ border.Left = 0
258+ }
259+ insetW := padding.horizontal() + border.horizontal()
260+ insetH := padding.vertical() + border.vertical()
261+ borderBox := s.BoxSizing == css.KeywordBorderBox
262+ w, explicitW := length(s.Width, availableW)
263+ h, explicitH := length(s.Height, availableH)
264+ if explicitW {
265+ w = bounded(w, s.MinWidth, s.MaxWidth, availableW)
266+ if !borderBox {
267+ w += insetW
268+ }
269+ }
270+ if explicitH {
271+ h = bounded(h, s.MinHeight, s.MaxHeight, availableH)
272+ if !borderBox {
273+ h += insetH
274+ }
275+ }
276+ contentW := safe(availableW - margin.horizontal() - insetW)
277+ if explicitW {
278+ contentW = safe(w - insetW)
279+ }
280+ measureW := contentW
281+ if root.Kind == Text && root.NoWrap {
282+ measureW = 0
283+ }
284+ measured, _, err := ctx.intrinsicDepth(root, measureW, 1)
285+ if err != nil {
286+ return Size{}, err
287+ }
288+ if !explicitW {
289+ w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox)
290+ }
291+ if !explicitH {
292+ h = boxBound(measured.H+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
293+ }
294+ return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil
295+}
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+
221313 // display paints the placed tree into a list sized once from the tree.
222314 func (c context) display(root *Node, tree *Result) []Command {
223315 n := countCommands(root, tree)
@@ -218,6 +218,98 @@ 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 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.
230+func Natural(root *Node, options Options) (Size, error) {
231+ if root == nil {
232+ return Size{}, nil
233+ }
234+ ctx := newContext(options)
235+ s := style(root)
236+ if s.Display == css.KeywordNone {
237+ return Size{}, nil
238+ }
239+ if err := validateStackRects(root, s); err != nil {
240+ return Size{}, err
241+ }
242+ s = IndefiniteHeights(s)
243+ availableW, availableH := safe(options.Width), safe(options.Height)
244+ margin := edges(s.Margin, availableW)
245+ padding := nonnegative(edges(s.Padding, availableW))
246+ border := nonnegative(edges(s.BorderWidth, availableW))
247+ if s.BorderStyle.Top != css.KeywordSolid {
248+ border.Top = 0
249+ }
250+ if s.BorderStyle.Right != css.KeywordSolid {
251+ border.Right = 0
252+ }
253+ if s.BorderStyle.Bottom != css.KeywordSolid {
254+ border.Bottom = 0
255+ }
256+ if s.BorderStyle.Left != css.KeywordSolid {
257+ border.Left = 0
258+ }
259+ insetW := padding.horizontal() + border.horizontal()
260+ insetH := padding.vertical() + border.vertical()
261+ borderBox := s.BoxSizing == css.KeywordBorderBox
262+ w, explicitW := length(s.Width, availableW)
263+ h, explicitH := length(s.Height, availableH)
264+ if explicitW {
265+ w = bounded(w, s.MinWidth, s.MaxWidth, availableW)
266+ if !borderBox {
267+ w += insetW
268+ }
269+ }
270+ if explicitH {
271+ h = bounded(h, s.MinHeight, s.MaxHeight, availableH)
272+ if !borderBox {
273+ h += insetH
274+ }
275+ }
276+ contentW := safe(availableW - margin.horizontal() - insetW)
277+ if explicitW {
278+ contentW = safe(w - insetW)
279+ }
280+ measureW := contentW
281+ if root.Kind == Text && root.NoWrap {
282+ measureW = 0
283+ }
284+ measured, _, err := ctx.intrinsicDepth(root, measureW, 1)
285+ if err != nil {
286+ return Size{}, err
287+ }
288+ if !explicitW {
289+ w = boxBound(measured.W+insetW, s.MinWidth, s.MaxWidth, availableW, insetW, borderBox)
290+ }
291+ if !explicitH {
292+ h = boxBound(measured.H+insetH, s.MinHeight, s.MaxHeight, availableH, insetH, borderBox)
293+ }
294+ return Size{safe(math.Max(w, insetW)), safe(math.Max(h, insetH))}, nil
295+}
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+
221 // 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.
222 func (c context) display(root *Node, tree *Result) []Command {314 func (c context) display(root *Node, tree *Result) []Command {
223 n := countCommands(root, tree)315 n := countCommands(root, tree)
A internal/layout/natural_test.go +203 -0
@@ -0,0 +1,203 @@
1+package layout
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+ "github.com/bnema/nefergui/internal/text"
9+)
10+
11+func naturalEngine(t *testing.T) *text.Engine {
12+ t.Helper()
13+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ return text.NewEngine(catalog)
18+}
19+
20+func textNode(id, content string) *Node {
21+ n := node(id, Text, css.Style{Display: css.KeywordBlock, FontSize: px(16)})
22+ n.Content = content
23+ return n
24+}
25+
26+func TestNaturalColumnOfTexts(t *testing.T) {
27+ engine := naturalEngine(t)
28+ short, long := textNode("a", "ab"), textNode("b", "a much longer line")
29+ 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)
30+ opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
31+ got, err := Natural(column, opts)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ ws, err := Natural(short, opts)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ wl, err := Natural(long, opts)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if !(wl.W > ws.W) || ws.W <= 0 {
44+ t.Fatalf("text widths short=%g long=%g", ws.W, wl.W)
45+ }
46+ if got.W != wl.W+12 || got.H != ws.H+wl.H+8 {
47+ t.Fatalf("column = %+v, want %g x %g", got, wl.W+12, ws.H+wl.H+8)
48+ }
49+}
50+
51+func TestNaturalRowSumsWithGap(t *testing.T) {
52+ engine := naturalEngine(t)
53+ a, b := textNode("a", "alpha"), textNode("b", "beta")
54+ opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
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+ }
63+ row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b)
64+ got, err := Natural(row, opts)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if got.W != wa.W+wb.W+10 || got.H != max(wa.H, wb.H) {
69+ t.Fatalf("row = %+v, want W %g H %g", got, wa.W+wb.W+10, max(wa.H, wb.H))
70+ }
71+}
72+
73+func TestNaturalExplicitWidthWins(t *testing.T) {
74+ engine := naturalEngine(t)
75+ child := textNode("a", "alpha beta gamma delta epsilon zeta")
76+ box := node("box", Box, css.Style{Display: css.KeywordBlock, Width: px(90), Padding: css.LengthSides{Left: px(5), Right: px(5)}}, child)
77+ got, err := Natural(box, Options{Width: 1000, Height: 1 << 20, TextEngine: engine})
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ if got.W != 100 {
82+ t.Fatalf("width = %g, want 100 (90 content + 10 padding)", got.W)
83+ }
84+ if got.H <= 16 {
85+ t.Fatalf("content did not wrap inside the explicit width: height %g", got.H)
86+ }
87+ 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)}})
88+ got, err = Natural(bb, Options{Width: 1000, Height: 1000})
89+ if err != nil || got.W != 90 || got.H != 30 {
90+ t.Fatalf("border-box = %+v err=%v", got, err)
91+ }
92+}
93+
94+func TestNaturalTextWrapsAtOptionsWidth(t *testing.T) {
95+ engine := naturalEngine(t)
96+ long := textNode("a", "alpha beta gamma delta epsilon zeta eta theta iota kappa")
97+ wide, err := Natural(long, Options{Width: 2000, Height: 1 << 20, TextEngine: engine})
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ narrow, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine})
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ // A wrapped line may measure slightly over the limit (103.75 for 100 here);
106+ // callers clamp to the limit, as Renderer.Measure does.
107+ if narrow.W >= wide.W || narrow.H <= wide.H {
108+ t.Fatalf("narrow=%+v wide=%+v", narrow, wide)
109+ }
110+}
111+
112+func TestNaturalNilAndHidden(t *testing.T) {
113+ if got, err := Natural(nil, Options{}); err != nil || got != (Size{}) {
114+ t.Fatalf("nil: %+v %v", got, err)
115+ }
116+ hidden := node("h", Box, css.Style{Display: css.KeywordNone})
117+ if got, err := Natural(hidden, Options{Width: 100}); err != nil || got != (Size{}) {
118+ t.Fatalf("hidden: %+v %v", got, err)
119+ }
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+}
@@ -0,0 +1,203 @@
1+package layout
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/bnema/nefergui/internal/css"
8+ "github.com/bnema/nefergui/internal/text"
9+)
10+
11+func naturalEngine(t *testing.T) *text.Engine {
12+ t.Helper()
13+ catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ return text.NewEngine(catalog)
18+}
19+
20+func textNode(id, content string) *Node {
21+ n := node(id, Text, css.Style{Display: css.KeywordBlock, FontSize: px(16)})
22+ n.Content = content
23+ return n
24+}
25+
26+func TestNaturalColumnOfTexts(t *testing.T) {
27+ engine := naturalEngine(t)
28+ short, long := textNode("a", "ab"), textNode("b", "a much longer line")
29+ 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)
30+ opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
31+ got, err := Natural(column, opts)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ ws, err := Natural(short, opts)
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ wl, err := Natural(long, opts)
40+ if err != nil {
41+ t.Fatal(err)
42+ }
43+ if !(wl.W > ws.W) || ws.W <= 0 {
44+ t.Fatalf("text widths short=%g long=%g", ws.W, wl.W)
45+ }
46+ if got.W != wl.W+12 || got.H != ws.H+wl.H+8 {
47+ t.Fatalf("column = %+v, want %g x %g", got, wl.W+12, ws.H+wl.H+8)
48+ }
49+}
50+
51+func TestNaturalRowSumsWithGap(t *testing.T) {
52+ engine := naturalEngine(t)
53+ a, b := textNode("a", "alpha"), textNode("b", "beta")
54+ opts := Options{Width: 1000, Height: 1 << 20, TextEngine: engine}
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+ }
63+ row := node("r", Row, css.Style{Display: css.KeywordFlex, ColumnGap: px(10)}, a, b)
64+ got, err := Natural(row, opts)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if got.W != wa.W+wb.W+10 || got.H != max(wa.H, wb.H) {
69+ t.Fatalf("row = %+v, want W %g H %g", got, wa.W+wb.W+10, max(wa.H, wb.H))
70+ }
71+}
72+
73+func TestNaturalExplicitWidthWins(t *testing.T) {
74+ engine := naturalEngine(t)
75+ child := textNode("a", "alpha beta gamma delta epsilon zeta")
76+ box := node("box", Box, css.Style{Display: css.KeywordBlock, Width: px(90), Padding: css.LengthSides{Left: px(5), Right: px(5)}}, child)
77+ got, err := Natural(box, Options{Width: 1000, Height: 1 << 20, TextEngine: engine})
78+ if err != nil {
79+ t.Fatal(err)
80+ }
81+ if got.W != 100 {
82+ t.Fatalf("width = %g, want 100 (90 content + 10 padding)", got.W)
83+ }
84+ if got.H <= 16 {
85+ t.Fatalf("content did not wrap inside the explicit width: height %g", got.H)
86+ }
87+ 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)}})
88+ got, err = Natural(bb, Options{Width: 1000, Height: 1000})
89+ if err != nil || got.W != 90 || got.H != 30 {
90+ t.Fatalf("border-box = %+v err=%v", got, err)
91+ }
92+}
93+
94+func TestNaturalTextWrapsAtOptionsWidth(t *testing.T) {
95+ engine := naturalEngine(t)
96+ long := textNode("a", "alpha beta gamma delta epsilon zeta eta theta iota kappa")
97+ wide, err := Natural(long, Options{Width: 2000, Height: 1 << 20, TextEngine: engine})
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ narrow, err := Natural(long, Options{Width: 100, Height: 1 << 20, TextEngine: engine})
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ // A wrapped line may measure slightly over the limit (103.75 for 100 here);
106+ // callers clamp to the limit, as Renderer.Measure does.
107+ if narrow.W >= wide.W || narrow.H <= wide.H {
108+ t.Fatalf("narrow=%+v wide=%+v", narrow, wide)
109+ }
110+}
111+
112+func TestNaturalNilAndHidden(t *testing.T) {
113+ if got, err := Natural(nil, Options{}); err != nil || got != (Size{}) {
114+ t.Fatalf("nil: %+v %v", got, err)
115+ }
116+ hidden := node("h", Box, css.Style{Display: css.KeywordNone})
117+ if got, err := Natural(hidden, Options{Width: 100}); err != nil || got != (Size{}) {
118+ t.Fatalf("hidden: %+v %v", got, err)
119+ }
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+}
M internal/ui/renderer.go +78 -0
@@ -283,6 +283,84 @@ func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (
283283 return true, nil
284284 }
285285
286+// Measure returns the logical size, in pixels rounded up, that view needs for
287+// model: its natural width, at most maxWidth, and the height the content takes
288+// at that width (text wraps at maxWidth). Use it to size a surface before
289+// creating it. The size includes the root's margins.
290+//
291+// The view is built in a temporary runtime that shares only this Renderer's
292+// styles and text engine. Those caches only grow during Measure: it does not
293+// age their entries, so a later Render finds its own entries intact. Hover,
294+// focus, scroll offsets, editor state, queued input, the Wake channel and the
295+// target are neither read nor changed, nothing is drawn, and the view sees no
296+// events.
297+//
298+// There is no surface yet, so the height is indefinite: inside the view
299+// Frame.Size reports an unbounded height (1<<20), and percentage height,
300+// min-height and max-height of the root are treated as auto, as for nested
301+// boxes, instead of resolving against that value. Lengths in pixels apply.
302+//
303+// Call it from the owner goroutine, before or after Resize, never from inside
304+// a view callback. It allocates and shapes text: use it when opening a
305+// surface, not on every frame. It returns an error when the Renderer is
306+// closed, model or view is nil, maxWidth is not a finite number above zero, or
307+// the view cannot be laid out (the layout error is wrapped). A view that
308+// declares no root measures 0 by 0.
309+func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) {
310+ if r.closed {
311+ return 0, 0, errClosed
312+ }
313+ if model == nil || view == nil {
314+ return 0, 0, errors.New("nefergui: nil model or view")
315+ }
316+ if math.IsNaN(maxWidth) || math.IsInf(maxWidth, 0) || maxWidth <= 0 {
317+ return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero")
318+ }
319+ const tall = 1 << 20
320+ tmp := &runtime{
321+ wake: make(chan struct{}, 1), styles: r.rt.styles, textEngine: r.rt.textEngine,
322+ width: maxWidth, height: tall, scale: 1, redraw: true,
323+ edits: make(map[string]*edit.State), measuring: true,
324+ }
325+ if !tmp.Build(func(f *Frame) { view(f, model) }) {
326+ if tmp.layoutErr != nil {
327+ return 0, 0, fmt.Errorf("nefergui: measure: %w", tmp.layoutErr)
328+ }
329+ return 0, 0, errors.New("nefergui: measure: could not build the view")
330+ }
331+ if tmp.committed == nil {
332+ return 0, 0, nil
333+ }
334+ root := tmp.layoutTree(tmp.committed)
335+ if root.Style != nil {
336+ st := layout.IndefiniteHeights(*root.Style)
337+ root.Style = &st
338+ }
339+ opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine}
340+ natural, err := layout.Natural(root, opts)
341+ if err != nil {
342+ return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
343+ }
344+ // Natural is a border box without margins, while the surface holds the
345+ // margin box: Layout subtracts the margins from the width it is given.
346+ var m layout.Edges
347+ if tmp.output.Tree != nil {
348+ m = tmp.output.Tree.Margin
349+ }
350+ w := math.Min(natural.W+math.Max(0, m.Left)+math.Max(0, m.Right), maxWidth)
351+ opts.Width = w
352+ out, err := layout.Layout(root, opts)
353+ if err != nil {
354+ return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
355+ }
356+ if out.Tree == nil {
357+ return 0, 0, nil
358+ }
359+ tree := out.Tree
360+ h := tree.Border.Y + tree.Border.H + math.Max(0, tree.Margin.Bottom)
361+ return math.Ceil(w), math.Ceil(h), nil
362+}
363+
286364 // export copies the target's frame description into out and adds the parts
287365 // derived from the UI: damage, cursor and the staged input region.
288366 func (r *Renderer) export(out *Output) {
@@ -283,6 +283,84 @@ func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (
283 return true, nil283 return true, nil
284 }284 }
285 285
286+// Measure returns the logical size, in pixels rounded up, that view needs for
287+// model: its natural width, at most maxWidth, and the height the content takes
288+// at that width (text wraps at maxWidth). Use it to size a surface before
289+// creating it. The size includes the root's margins.
290+//
291+// The view is built in a temporary runtime that shares only this Renderer's
292+// styles and text engine. Those caches only grow during Measure: it does not
293+// age their entries, so a later Render finds its own entries intact. Hover,
294+// focus, scroll offsets, editor state, queued input, the Wake channel and the
295+// target are neither read nor changed, nothing is drawn, and the view sees no
296+// events.
297+//
298+// There is no surface yet, so the height is indefinite: inside the view
299+// Frame.Size reports an unbounded height (1<<20), and percentage height,
300+// min-height and max-height of the root are treated as auto, as for nested
301+// boxes, instead of resolving against that value. Lengths in pixels apply.
302+//
303+// Call it from the owner goroutine, before or after Resize, never from inside
304+// a view callback. It allocates and shapes text: use it when opening a
305+// surface, not on every frame. It returns an error when the Renderer is
306+// closed, model or view is nil, maxWidth is not a finite number above zero, or
307+// the view cannot be laid out (the layout error is wrapped). A view that
308+// declares no root measures 0 by 0.
309+func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) {
310+ if r.closed {
311+ return 0, 0, errClosed
312+ }
313+ if model == nil || view == nil {
314+ return 0, 0, errors.New("nefergui: nil model or view")
315+ }
316+ if math.IsNaN(maxWidth) || math.IsInf(maxWidth, 0) || maxWidth <= 0 {
317+ return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero")
318+ }
319+ const tall = 1 << 20
320+ tmp := &runtime{
321+ wake: make(chan struct{}, 1), styles: r.rt.styles, textEngine: r.rt.textEngine,
322+ width: maxWidth, height: tall, scale: 1, redraw: true,
323+ edits: make(map[string]*edit.State), measuring: true,
324+ }
325+ if !tmp.Build(func(f *Frame) { view(f, model) }) {
326+ if tmp.layoutErr != nil {
327+ return 0, 0, fmt.Errorf("nefergui: measure: %w", tmp.layoutErr)
328+ }
329+ return 0, 0, errors.New("nefergui: measure: could not build the view")
330+ }
331+ if tmp.committed == nil {
332+ return 0, 0, nil
333+ }
334+ root := tmp.layoutTree(tmp.committed)
335+ if root.Style != nil {
336+ st := layout.IndefiniteHeights(*root.Style)
337+ root.Style = &st
338+ }
339+ opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine}
340+ natural, err := layout.Natural(root, opts)
341+ if err != nil {
342+ return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
343+ }
344+ // Natural is a border box without margins, while the surface holds the
345+ // margin box: Layout subtracts the margins from the width it is given.
346+ var m layout.Edges
347+ if tmp.output.Tree != nil {
348+ m = tmp.output.Tree.Margin
349+ }
350+ w := math.Min(natural.W+math.Max(0, m.Left)+math.Max(0, m.Right), maxWidth)
351+ opts.Width = w
352+ out, err := layout.Layout(root, opts)
353+ if err != nil {
354+ return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
355+ }
356+ if out.Tree == nil {
357+ return 0, 0, nil
358+ }
359+ tree := out.Tree
360+ h := tree.Border.Y + tree.Border.H + math.Max(0, tree.Margin.Bottom)
361+ return math.Ceil(w), math.Ceil(h), nil
362+}
363+
286 // export copies the target's frame description into out and adds the parts364 // export copies the target's frame description into out and adds the parts
287 // derived from the UI: damage, cursor and the staged input region.365 // derived from the UI: damage, cursor and the staged input region.
288 func (r *Renderer) export(out *Output) {366 func (r *Renderer) export(out *Output) {
M internal/ui/renderer_test.go +299 -0
@@ -5,8 +5,11 @@ package ui
55 import (
66 "errors"
77 "fmt"
8+ "math"
9+ "strings"
810 "testing"
911
12+ "github.com/bnema/nefergui/internal/css"
1013 "github.com/bnema/nefergui/internal/layout"
1114 "github.com/bnema/nefergui/internal/presentation/session"
1215 "github.com/bnema/nefergui/internal/text"
@@ -354,3 +357,299 @@ func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
354357 })
355358 }
356359 }
360+
361+type measureModel struct{ title, body string }
362+
363+func measureView(f *Frame, m *measureModel) {
364+ root := f.Root(Inline("padding:4px"))
365+ root.Text(m.title)
366+ root.Text(m.body)
367+}
368+
369+func TestRendererMeasureNaturalSizeBeforeResize(t *testing.T) {
370+ r, _ := newTestRenderer(t) // the mock target has no expectations: any target call fails
371+ m := measureModel{title: "Title", body: "short"}
372+ w, h, err := r.Measure(&m, measureView, 400)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ if w <= 8 || w >= 400 || h <= 8 || w != float64(int(w)) || h != float64(int(h)) {
377+ t.Fatalf("size %g x %g", w, h)
378+ }
379+ single := h
380+ m.body = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau"
381+ w, h, err = r.Measure(&m, measureView, 100)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if w != 100 || h <= single+10 {
386+ t.Fatalf("wrapped size %g x %g, one-line height %g", w, h, single)
387+ }
388+}
389+
390+func TestRendererMeasureValidation(t *testing.T) {
391+ r, _ := newTestRenderer(t)
392+ m := measureModel{}
393+ for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1)} {
394+ if _, _, err := r.Measure(&m, measureView, bad); err == nil {
395+ t.Errorf("maxWidth %v accepted", bad)
396+ }
397+ }
398+ if _, _, err := r.Measure[measureModel](nil, measureView, 100); err == nil {
399+ t.Error("nil model accepted")
400+ }
401+ if _, _, err := r.Measure(&m, nil, 100); err == nil {
402+ t.Error("nil view accepted")
403+ }
404+ if w, h, err := r.Measure(&m, func(*Frame, *measureModel) {}, 100); err != nil || w != 0 || h != 0 {
405+ t.Errorf("view without root: %g x %g %v", w, h, err)
406+ }
407+ tg := r.target.(*mocktarget)
408+ tg.EXPECT().Close().Return(nil).Once()
409+ _ = r.Close()
410+ if _, _, err := r.Measure(&m, measureView, 100); err == nil {
411+ t.Error("closed renderer accepted")
412+ }
413+}
414+
415+func TestRendererMeasureChangesNoState(t *testing.T) {
416+ r, tg := newTestRenderer(t)
417+ type model struct {
418+ clicks int
419+ text string
420+ }
421+ m := model{text: "edit me"}
422+ view := func(f *Frame, m *model) {
423+ root := f.Root()
424+ if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
425+ m.clicks++
426+ }
427+ root.Input("field", &m.text, Key("field"))
428+ }
429+ var out Output
430+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
431+ r.Resize(200, 100, 1)
432+ drawOK(tg, nil).Maybe()
433+ render := func() {
434+ t.Helper()
435+ for i := 0; i < 3; i++ {
436+ if _, err := r.Render(&out, &m, view); err != nil {
437+ t.Fatal(err)
438+ }
439+ }
440+ }
441+ render()
442+ r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5})
443+ r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true}) // Tab: focus the button
444+ render()
445+ draws := len(tg.Calls)
446+ rt := r.rt
447+ if rt.state.hover == nil || rt.state.focus == nil {
448+ t.Fatalf("setup: hover=%v focus=%v", rt.state.hover, rt.state.focus)
449+ }
450+ // Input queued but not yet built must survive Measure untouched.
451+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
452+ r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
453+ before := struct {
454+ state interactionState
455+ committed *element
456+ generation uint64
457+ edits, pending, wake int
458+ styles any
459+ engine any
460+ }{rt.state, rt.committed, rt.generation, len(rt.edits), len(rt.pending), len(rt.wake), rt.styles, rt.textEngine}
461+ if before.pending == 0 || before.wake == 0 {
462+ t.Fatalf("setup: pending=%d wake=%d", before.pending, before.wake)
463+ }
464+
465+ if _, _, err := r.Measure(&m, view, 300); err != nil {
466+ t.Fatal(err)
467+ }
468+
469+ if rt.state.hover != before.state.hover || rt.state.focus != before.state.focus || rt.state.x != before.state.x || rt.state.y != before.state.y || rt.state.inside != before.state.inside || rt.state.down != before.state.down {
470+ t.Fatalf("interaction state changed: %+v -> %+v", before.state, rt.state)
471+ }
472+ if rt.committed != before.committed || rt.generation != before.generation {
473+ t.Fatal("committed tree or generation changed")
474+ }
475+ if len(rt.edits) != before.edits || rt.styles != before.styles || rt.textEngine != before.engine {
476+ t.Fatal("runtime parts changed")
477+ }
478+ if len(rt.pending) != before.pending || len(rt.wake) != before.wake {
479+ t.Fatalf("pending %d -> %d, wake %d -> %d", before.pending, len(rt.pending), before.wake, len(rt.wake))
480+ }
481+ if m.clicks != 0 || m.text != "edit me" {
482+ t.Fatalf("model changed: %+v", m)
483+ }
484+ if len(tg.Calls) != draws {
485+ t.Fatalf("target called by Measure: %d -> %d calls", draws, len(tg.Calls))
486+ }
487+ render()
488+ if m.clicks != 1 {
489+ t.Fatalf("the click queued before Measure was lost: clicks=%d", m.clicks)
490+ }
491+ if rt.state.hover == nil || rt.state.focus == nil {
492+ t.Fatalf("hover/focus lost after Render: %+v", rt.state)
493+ }
494+}
495+
496+const measureLong = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega"
497+
498+func TestRendererMeasureRootMargins(t *testing.T) {
499+ r, _ := newTestRenderer(t)
500+ m := measureModel{body: measureLong}
501+ view := func(f *Frame, m *measureModel) {
502+ root := f.Root(Inline("margin:20px;padding:3px"))
503+ root.Text(m.body)
504+ }
505+ const maxWidth = 300
506+ w, h, err := r.Measure(&m, view, maxWidth)
507+ if err != nil {
508+ t.Fatal(err)
509+ }
510+ // The widest wrapped line plus padding and margins: close to, never over, the limit.
511+ if w > maxWidth || w < maxWidth-60 {
512+ t.Fatalf("width %g, want within 60px below %d", w, maxWidth)
513+ }
514+ // Lay the same view out at the measured size, as a surface would.
515+ rt := newRuntime()
516+ rt.styles, rt.textEngine = r.rt.styles, r.rt.textEngine
517+ rt.width, rt.height = w, h
518+ rt.redraw = true
519+ if !rt.Build(func(f *Frame) { view(f, &m) }) {
520+ t.Fatal("build failed")
521+ }
522+ tree := rt.output.Tree
523+ if tree == nil {
524+ t.Fatal("no layout")
525+ }
526+ right, bottom := tree.Border.X+tree.Border.W+tree.Margin.Right, tree.Border.Y+tree.Border.H+tree.Margin.Bottom
527+ if right > w+0.01 || bottom > h+0.01 || bottom < h-1 {
528+ t.Fatalf("laid-out margin box %g x %g does not fit the measured %g x %g", right, bottom, w, h)
529+ }
530+ if tree.Border.X != 20 || tree.Border.Y != 20 {
531+ t.Fatalf("margins not applied: %+v", tree.Border)
532+ }
533+ if len(tree.Children) != 1 || len(tree.Children[0].Lines) < 2 {
534+ t.Fatalf("text did not wrap at the measured width: %+v", tree.Children)
535+ }
536+ // Without the margin, the same text must be shorter than with it: the
537+ // margin narrows the wrap width by 40px and adds 40px of height.
538+ _, plain, err := r.Measure(&m, func(f *Frame, m *measureModel) { f.Root(Inline("padding:3px")).Text(m.body) }, maxWidth)
539+ if err != nil {
540+ t.Fatal(err)
541+ }
542+ if h < plain+40 {
543+ t.Fatalf("height %g with margins, %g without: margins missing", h, plain)
544+ }
545+}
546+
547+func TestRendererMeasurePercentHeightsAreIndefinite(t *testing.T) {
548+ r, _ := newTestRenderer(t)
549+ m := measureModel{title: "Title", body: "body"}
550+ build := func(rootStyle string) func(*Frame, *measureModel) {
551+ return func(f *Frame, m *measureModel) {
552+ root := f.Root(Inline(rootStyle))
553+ root.Text(m.title)
554+ root.Text(m.body)
555+ }
556+ }
557+ _, auto, err := r.Measure(&m, build("padding:4px"), 200)
558+ if err != nil {
559+ t.Fatal(err)
560+ }
561+ if auto <= 8 || auto > 200 {
562+ t.Fatalf("setup: content height %g", auto)
563+ }
564+ for _, style := range []string{
565+ "padding:4px;height:100%",
566+ "padding:4px;min-height:50%",
567+ "padding:4px;max-height:50%",
568+ "padding:4px;height:100%;min-height:50%;max-height:100%",
569+ } {
570+ _, h, err := r.Measure(&m, build(style), 200)
571+ if err != nil {
572+ t.Fatalf("%s: %v", style, err)
573+ }
574+ if h != auto {
575+ t.Errorf("%q: height %g, want the content height %g", style, h, auto)
576+ }
577+ }
578+ // Pixel heights still apply.
579+ if _, h, err := r.Measure(&m, build("height:123px"), 200); err != nil || h != 123 {
580+ t.Errorf("height:123px gave %g, %v", h, err)
581+ }
582+ var seen float64
583+ _, _, err = r.Measure(&m, func(f *Frame, m *measureModel) {
584+ _, seen = f.Size()
585+ f.Root().Text(m.title)
586+ }, 200)
587+ if err != nil || seen < 1<<20 {
588+ t.Errorf("Frame.Size height %g (err %v), want unbounded", seen, err)
589+ }
590+}
591+
592+func TestRendererMeasureLayoutError(t *testing.T) {
593+ r, _ := newTestRenderer(t)
594+ m := measureModel{}
595+ flexStackWithRect := func(f *Frame, _ *measureModel) {
596+ root := f.Root()
597+ stack := root.Stack(Inline("display:flex"))
598+ stack.Box().Rect(0, 0, 10, 10)
599+ }
600+ _, _, err := r.Measure(&m, flexStackWithRect, 100)
601+ if err == nil || !strings.HasPrefix(err.Error(), "nefergui: measure: ") || !strings.Contains(err.Error(), "Rect") {
602+ t.Fatalf("flex Stack with a Rect child: %v", err)
603+ }
604+ deep := func(f *Frame, _ *measureModel) {
605+ n := f.Root()
606+ for i := 0; i < 300; i++ {
607+ n = n.Box()
608+ }
609+ n.Text("x")
610+ }
611+ _, _, err = r.Measure(&m, deep, 100)
612+ if !errors.Is(err, layout.ErrDepth) || !strings.HasPrefix(err.Error(), "nefergui: measure: ") {
613+ t.Fatalf("deep tree: %v, want a wrapped ErrDepth", err)
614+ }
615+ // The Renderer stays usable.
616+ if _, _, err := r.Measure(&m, measureView, 100); err != nil {
617+ t.Fatalf("after errors: %v", err)
618+ }
619+}
620+
621+func TestRendererMeasureDoesNotAgeSharedCaches(t *testing.T) {
622+ r, _ := newTestRenderer(t)
623+ m := measureModel{title: "one", body: "two"}
624+ view := func(f *Frame, m *measureModel) {
625+ root := f.Root(Class("rendered"))
626+ root.Text(m.title)
627+ root.Text(m.body)
628+ }
629+ other := func(f *Frame, m *measureModel) {
630+ f.Root(Class("other")).Text("unrelated " + m.title)
631+ }
632+ rt := r.rt
633+ build := func() *css.Computed {
634+ t.Helper()
635+ rt.Redraw()
636+ if !rt.Build(func(f *Frame) { view(f, &m) }) {
637+ t.Fatal("build failed")
638+ }
639+ return rt.committed.computed
640+ }
641+ first := build()
642+ if first == nil {
643+ t.Fatal("no computed style")
644+ }
645+ // Each Build that called EndFrame would age the entries out: after three
646+ // builds of another view the "rendered" style would be recomputed.
647+ for i := 0; i < 3; i++ {
648+ if _, _, err := r.Measure(&m, other, 200); err != nil {
649+ t.Fatal(err)
650+ }
651+ }
652+ if again := build(); again != first {
653+ t.Fatal("Measure aged the shared style cache: the next Render recomputed the root style")
654+ }
655+}
@@ -5,8 +5,11 @@ package ui
5 import (5 import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8+ "math"
9+ "strings"
8 "testing"10 "testing"
9 11
12+ "github.com/bnema/nefergui/internal/css"
10 "github.com/bnema/nefergui/internal/layout"13 "github.com/bnema/nefergui/internal/layout"
11 "github.com/bnema/nefergui/internal/presentation/session"14 "github.com/bnema/nefergui/internal/presentation/session"
12 "github.com/bnema/nefergui/internal/text"15 "github.com/bnema/nefergui/internal/text"
@@ -354,3 +357,299 @@ func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
354 })357 })
355 }358 }
356 }359 }
360+
361+type measureModel struct{ title, body string }
362+
363+func measureView(f *Frame, m *measureModel) {
364+ root := f.Root(Inline("padding:4px"))
365+ root.Text(m.title)
366+ root.Text(m.body)
367+}
368+
369+func TestRendererMeasureNaturalSizeBeforeResize(t *testing.T) {
370+ r, _ := newTestRenderer(t) // the mock target has no expectations: any target call fails
371+ m := measureModel{title: "Title", body: "short"}
372+ w, h, err := r.Measure(&m, measureView, 400)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ if w <= 8 || w >= 400 || h <= 8 || w != float64(int(w)) || h != float64(int(h)) {
377+ t.Fatalf("size %g x %g", w, h)
378+ }
379+ single := h
380+ m.body = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau"
381+ w, h, err = r.Measure(&m, measureView, 100)
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ if w != 100 || h <= single+10 {
386+ t.Fatalf("wrapped size %g x %g, one-line height %g", w, h, single)
387+ }
388+}
389+
390+func TestRendererMeasureValidation(t *testing.T) {
391+ r, _ := newTestRenderer(t)
392+ m := measureModel{}
393+ for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1)} {
394+ if _, _, err := r.Measure(&m, measureView, bad); err == nil {
395+ t.Errorf("maxWidth %v accepted", bad)
396+ }
397+ }
398+ if _, _, err := r.Measure[measureModel](nil, measureView, 100); err == nil {
399+ t.Error("nil model accepted")
400+ }
401+ if _, _, err := r.Measure(&m, nil, 100); err == nil {
402+ t.Error("nil view accepted")
403+ }
404+ if w, h, err := r.Measure(&m, func(*Frame, *measureModel) {}, 100); err != nil || w != 0 || h != 0 {
405+ t.Errorf("view without root: %g x %g %v", w, h, err)
406+ }
407+ tg := r.target.(*mocktarget)
408+ tg.EXPECT().Close().Return(nil).Once()
409+ _ = r.Close()
410+ if _, _, err := r.Measure(&m, measureView, 100); err == nil {
411+ t.Error("closed renderer accepted")
412+ }
413+}
414+
415+func TestRendererMeasureChangesNoState(t *testing.T) {
416+ r, tg := newTestRenderer(t)
417+ type model struct {
418+ clicks int
419+ text string
420+ }
421+ m := model{text: "edit me"}
422+ view := func(f *Frame, m *model) {
423+ root := f.Root()
424+ if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
425+ m.clicks++
426+ }
427+ root.Input("field", &m.text, Key("field"))
428+ }
429+ var out Output
430+ tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
431+ r.Resize(200, 100, 1)
432+ drawOK(tg, nil).Maybe()
433+ render := func() {
434+ t.Helper()
435+ for i := 0; i < 3; i++ {
436+ if _, err := r.Render(&out, &m, view); err != nil {
437+ t.Fatal(err)
438+ }
439+ }
440+ }
441+ render()
442+ r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5})
443+ r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true}) // Tab: focus the button
444+ render()
445+ draws := len(tg.Calls)
446+ rt := r.rt
447+ if rt.state.hover == nil || rt.state.focus == nil {
448+ t.Fatalf("setup: hover=%v focus=%v", rt.state.hover, rt.state.focus)
449+ }
450+ // Input queued but not yet built must survive Measure untouched.
451+ r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
452+ r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
453+ before := struct {
454+ state interactionState
455+ committed *element
456+ generation uint64
457+ edits, pending, wake int
458+ styles any
459+ engine any
460+ }{rt.state, rt.committed, rt.generation, len(rt.edits), len(rt.pending), len(rt.wake), rt.styles, rt.textEngine}
461+ if before.pending == 0 || before.wake == 0 {
462+ t.Fatalf("setup: pending=%d wake=%d", before.pending, before.wake)
463+ }
464+
465+ if _, _, err := r.Measure(&m, view, 300); err != nil {
466+ t.Fatal(err)
467+ }
468+
469+ if rt.state.hover != before.state.hover || rt.state.focus != before.state.focus || rt.state.x != before.state.x || rt.state.y != before.state.y || rt.state.inside != before.state.inside || rt.state.down != before.state.down {
470+ t.Fatalf("interaction state changed: %+v -> %+v", before.state, rt.state)
471+ }
472+ if rt.committed != before.committed || rt.generation != before.generation {
473+ t.Fatal("committed tree or generation changed")
474+ }
475+ if len(rt.edits) != before.edits || rt.styles != before.styles || rt.textEngine != before.engine {
476+ t.Fatal("runtime parts changed")
477+ }
478+ if len(rt.pending) != before.pending || len(rt.wake) != before.wake {
479+ t.Fatalf("pending %d -> %d, wake %d -> %d", before.pending, len(rt.pending), before.wake, len(rt.wake))
480+ }
481+ if m.clicks != 0 || m.text != "edit me" {
482+ t.Fatalf("model changed: %+v", m)
483+ }
484+ if len(tg.Calls) != draws {
485+ t.Fatalf("target called by Measure: %d -> %d calls", draws, len(tg.Calls))
486+ }
487+ render()
488+ if m.clicks != 1 {
489+ t.Fatalf("the click queued before Measure was lost: clicks=%d", m.clicks)
490+ }
491+ if rt.state.hover == nil || rt.state.focus == nil {
492+ t.Fatalf("hover/focus lost after Render: %+v", rt.state)
493+ }
494+}
495+
496+const measureLong = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega"
497+
498+func TestRendererMeasureRootMargins(t *testing.T) {
499+ r, _ := newTestRenderer(t)
500+ m := measureModel{body: measureLong}
501+ view := func(f *Frame, m *measureModel) {
502+ root := f.Root(Inline("margin:20px;padding:3px"))
503+ root.Text(m.body)
504+ }
505+ const maxWidth = 300
506+ w, h, err := r.Measure(&m, view, maxWidth)
507+ if err != nil {
508+ t.Fatal(err)
509+ }
510+ // The widest wrapped line plus padding and margins: close to, never over, the limit.
511+ if w > maxWidth || w < maxWidth-60 {
512+ t.Fatalf("width %g, want within 60px below %d", w, maxWidth)
513+ }
514+ // Lay the same view out at the measured size, as a surface would.
515+ rt := newRuntime()
516+ rt.styles, rt.textEngine = r.rt.styles, r.rt.textEngine
517+ rt.width, rt.height = w, h
518+ rt.redraw = true
519+ if !rt.Build(func(f *Frame) { view(f, &m) }) {
520+ t.Fatal("build failed")
521+ }
522+ tree := rt.output.Tree
523+ if tree == nil {
524+ t.Fatal("no layout")
525+ }
526+ right, bottom := tree.Border.X+tree.Border.W+tree.Margin.Right, tree.Border.Y+tree.Border.H+tree.Margin.Bottom
527+ if right > w+0.01 || bottom > h+0.01 || bottom < h-1 {
528+ t.Fatalf("laid-out margin box %g x %g does not fit the measured %g x %g", right, bottom, w, h)
529+ }
530+ if tree.Border.X != 20 || tree.Border.Y != 20 {
531+ t.Fatalf("margins not applied: %+v", tree.Border)
532+ }
533+ if len(tree.Children) != 1 || len(tree.Children[0].Lines) < 2 {
534+ t.Fatalf("text did not wrap at the measured width: %+v", tree.Children)
535+ }
536+ // Without the margin, the same text must be shorter than with it: the
537+ // margin narrows the wrap width by 40px and adds 40px of height.
538+ _, plain, err := r.Measure(&m, func(f *Frame, m *measureModel) { f.Root(Inline("padding:3px")).Text(m.body) }, maxWidth)
539+ if err != nil {
540+ t.Fatal(err)
541+ }
542+ if h < plain+40 {
543+ t.Fatalf("height %g with margins, %g without: margins missing", h, plain)
544+ }
545+}
546+
547+func TestRendererMeasurePercentHeightsAreIndefinite(t *testing.T) {
548+ r, _ := newTestRenderer(t)
549+ m := measureModel{title: "Title", body: "body"}
550+ build := func(rootStyle string) func(*Frame, *measureModel) {
551+ return func(f *Frame, m *measureModel) {
552+ root := f.Root(Inline(rootStyle))
553+ root.Text(m.title)
554+ root.Text(m.body)
555+ }
556+ }
557+ _, auto, err := r.Measure(&m, build("padding:4px"), 200)
558+ if err != nil {
559+ t.Fatal(err)
560+ }
561+ if auto <= 8 || auto > 200 {
562+ t.Fatalf("setup: content height %g", auto)
563+ }
564+ for _, style := range []string{
565+ "padding:4px;height:100%",
566+ "padding:4px;min-height:50%",
567+ "padding:4px;max-height:50%",
568+ "padding:4px;height:100%;min-height:50%;max-height:100%",
569+ } {
570+ _, h, err := r.Measure(&m, build(style), 200)
571+ if err != nil {
572+ t.Fatalf("%s: %v", style, err)
573+ }
574+ if h != auto {
575+ t.Errorf("%q: height %g, want the content height %g", style, h, auto)
576+ }
577+ }
578+ // Pixel heights still apply.
579+ if _, h, err := r.Measure(&m, build("height:123px"), 200); err != nil || h != 123 {
580+ t.Errorf("height:123px gave %g, %v", h, err)
581+ }
582+ var seen float64
583+ _, _, err = r.Measure(&m, func(f *Frame, m *measureModel) {
584+ _, seen = f.Size()
585+ f.Root().Text(m.title)
586+ }, 200)
587+ if err != nil || seen < 1<<20 {
588+ t.Errorf("Frame.Size height %g (err %v), want unbounded", seen, err)
589+ }
590+}
591+
592+func TestRendererMeasureLayoutError(t *testing.T) {
593+ r, _ := newTestRenderer(t)
594+ m := measureModel{}
595+ flexStackWithRect := func(f *Frame, _ *measureModel) {
596+ root := f.Root()
597+ stack := root.Stack(Inline("display:flex"))
598+ stack.Box().Rect(0, 0, 10, 10)
599+ }
600+ _, _, err := r.Measure(&m, flexStackWithRect, 100)
601+ if err == nil || !strings.HasPrefix(err.Error(), "nefergui: measure: ") || !strings.Contains(err.Error(), "Rect") {
602+ t.Fatalf("flex Stack with a Rect child: %v", err)
603+ }
604+ deep := func(f *Frame, _ *measureModel) {
605+ n := f.Root()
606+ for i := 0; i < 300; i++ {
607+ n = n.Box()
608+ }
609+ n.Text("x")
610+ }
611+ _, _, err = r.Measure(&m, deep, 100)
612+ if !errors.Is(err, layout.ErrDepth) || !strings.HasPrefix(err.Error(), "nefergui: measure: ") {
613+ t.Fatalf("deep tree: %v, want a wrapped ErrDepth", err)
614+ }
615+ // The Renderer stays usable.
616+ if _, _, err := r.Measure(&m, measureView, 100); err != nil {
617+ t.Fatalf("after errors: %v", err)
618+ }
619+}
620+
621+func TestRendererMeasureDoesNotAgeSharedCaches(t *testing.T) {
622+ r, _ := newTestRenderer(t)
623+ m := measureModel{title: "one", body: "two"}
624+ view := func(f *Frame, m *measureModel) {
625+ root := f.Root(Class("rendered"))
626+ root.Text(m.title)
627+ root.Text(m.body)
628+ }
629+ other := func(f *Frame, m *measureModel) {
630+ f.Root(Class("other")).Text("unrelated " + m.title)
631+ }
632+ rt := r.rt
633+ build := func() *css.Computed {
634+ t.Helper()
635+ rt.Redraw()
636+ if !rt.Build(func(f *Frame) { view(f, &m) }) {
637+ t.Fatal("build failed")
638+ }
639+ return rt.committed.computed
640+ }
641+ first := build()
642+ if first == nil {
643+ t.Fatal("no computed style")
644+ }
645+ // Each Build that called EndFrame would age the entries out: after three
646+ // builds of another view the "rendered" style would be recomputed.
647+ for i := 0; i < 3; i++ {
648+ if _, _, err := r.Measure(&m, other, 200); err != nil {
649+ t.Fatal(err)
650+ }
651+ }
652+ if again := build(); again != first {
653+ t.Fatal("Measure aged the shared style cache: the next Render recomputed the root style")
654+ }
655+}
M internal/ui/runtime.go +15 -3
@@ -50,6 +50,13 @@ type runtime struct {
5050 redraw bool
5151 inputRects []Rect
5252 inputNil, inputStaged bool
53+ // layoutErr is the error of the last layout that made Build fail, nil
54+ // after a layout that succeeded. Measure reports it.
55+ layoutErr error
56+ // measuring marks the temporary runtime of Renderer.Measure: it shares the
57+ // style and text caches of a Renderer, which only that Renderer's frames
58+ // may age.
59+ measuring bool
5360 }
5461
5562 func newRuntime() *runtime {
@@ -134,9 +141,11 @@ func (r *runtime) Build(view func(*Frame)) bool {
134141 f := &r.frame
135142 defer func() {
136143 f.active = false
137- r.styles.EndFrame()
138- if r.textEngine != nil {
139- r.textEngine.EndFrame()
144+ if !r.measuring {
145+ r.styles.EndFrame()
146+ if r.textEngine != nil {
147+ r.textEngine.EndFrame()
148+ }
140149 }
141150 // A panicking view is not committed; the next build may retry.
142151 if recovered := recover(); recovered != nil {
@@ -148,6 +157,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
148157 r.mu.Lock()
149158 // State can change while the view runs. Schedule another frame instead of
150159 // overwriting that newer input with this frame's snapshot.
160+ r.layoutErr = nil
151161 if f.root != nil {
152162 f.refresh(f.root)
153163 input := r.layoutTree(f.root)
@@ -157,6 +167,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
157167 opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
158168 out, err := layout.Layout(input, opts)
159169 if err != nil {
170+ r.layoutErr = err
160171 r.mu.Unlock()
161172 r.Redraw()
162173 return false
@@ -168,6 +179,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
168179 input = r.layoutTree(f.root)
169180 out, err = layout.Layout(input, opts)
170181 if err != nil {
182+ r.layoutErr = err
171183 r.mu.Unlock()
172184 r.Redraw()
173185 return false
@@ -50,6 +50,13 @@ type runtime struct {
50 redraw bool50 redraw bool
51 inputRects []Rect51 inputRects []Rect
52 inputNil, inputStaged bool52 inputNil, inputStaged bool
53+ // layoutErr is the error of the last layout that made Build fail, nil
54+ // after a layout that succeeded. Measure reports it.
55+ layoutErr error
56+ // measuring marks the temporary runtime of Renderer.Measure: it shares the
57+ // style and text caches of a Renderer, which only that Renderer's frames
58+ // may age.
59+ measuring bool
53 }60 }
54 61
55 func newRuntime() *runtime {62 func newRuntime() *runtime {
@@ -134,9 +141,11 @@ func (r *runtime) Build(view func(*Frame)) bool {
134 f := &r.frame141 f := &r.frame
135 defer func() {142 defer func() {
136 f.active = false143 f.active = false
137- r.styles.EndFrame()144+ if !r.measuring {
138- if r.textEngine != nil {145+ r.styles.EndFrame()
139- r.textEngine.EndFrame()146+ if r.textEngine != nil {
147+ r.textEngine.EndFrame()
148+ }
140 }149 }
141 // A panicking view is not committed; the next build may retry.150 // A panicking view is not committed; the next build may retry.
142 if recovered := recover(); recovered != nil {151 if recovered := recover(); recovered != nil {
@@ -148,6 +157,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
148 r.mu.Lock()157 r.mu.Lock()
149 // State can change while the view runs. Schedule another frame instead of158 // State can change while the view runs. Schedule another frame instead of
150 // overwriting that newer input with this frame's snapshot.159 // overwriting that newer input with this frame's snapshot.
160+ r.layoutErr = nil
151 if f.root != nil {161 if f.root != nil {
152 f.refresh(f.root)162 f.refresh(f.root)
153 input := r.layoutTree(f.root)163 input := r.layoutTree(f.root)
@@ -157,6 +167,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
157 opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}167 opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]}
158 out, err := layout.Layout(input, opts)168 out, err := layout.Layout(input, opts)
159 if err != nil {169 if err != nil {
170+ r.layoutErr = err
160 r.mu.Unlock()171 r.mu.Unlock()
161 r.Redraw()172 r.Redraw()
162 return false173 return false
@@ -168,6 +179,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
168 input = r.layoutTree(f.root)179 input = r.layoutTree(f.root)
169 out, err = layout.Layout(input, opts)180 out, err = layout.Layout(input, opts)
170 if err != nil {181 if err != nil {
182+ r.layoutErr = err
171 r.mu.Unlock()183 r.mu.Unlock()
172 r.Redraw()184 r.Redraw()
173 return false185 return false
M renderer.go +5 -0
@@ -16,6 +16,11 @@ import "github.com/bnema/nefergui/internal/ui"
1616 // Render is generic over the model type, so it is a generic method:
1717 //
1818 // ok, err := r.Render(&out, &model, view)
19+//
20+// Measure sizes a view before a surface exists (owner goroutine, not for every
21+// frame); it changes no Renderer state:
22+//
23+// width, height, err := r.Measure(&model, view, maxWidth)
1924 type Renderer = ui.Renderer
2025
2126 // RendererConfig configures NewRenderer. MainDevice and Formats come from the
@@ -16,6 +16,11 @@ import "github.com/bnema/nefergui/internal/ui"
16 // Render is generic over the model type, so it is a generic method:16 // Render is generic over the model type, so it is a generic method:
17 //17 //
18 // ok, err := r.Render(&out, &model, view)18 // ok, err := r.Render(&out, &model, view)
19+//
20+// Measure sizes a view before a surface exists (owner goroutine, not for every
21+// frame); it changes no Renderer state:
22+//
23+// width, height, err := r.Measure(&model, view, maxWidth)
19 type Renderer = ui.Renderer24 type Renderer = ui.Renderer
20 25
21 // RendererConfig configures NewRenderer. MainDevice and Formats come from the26 // RendererConfig configures NewRenderer. MainDevice and Formats come from the

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