detached fe167f0

fix(ui): measure root margins and percentage heights

Measure includes the root margins in the returned size, treats percentage heights of the root as auto, keeps the real layout error, no longer ages the shared style and text caches, and builds its temporary runtime directly instead of compiling a throwaway UA engine.
commit
fe167f0aabae0cd34063a730ea1b379de7b01d83
author
brice · · 2026-10-09 03:52 UTC
parent
cb70a9a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +220 -20
M docs/runtime.md +1 -1
@@ -7,7 +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`. 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. It allocates and shapes text: call it from the owner goroutine when opening a surface, not on every frame. It works before `Resize` and returns an error for a closed Renderer, a nil model or view, or a `maxWidth` that is not finite and above zero.
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).
1111 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
1212 - `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`.
1313 - `Close` cancels pending pastes and frees GPU resources.
@@ -7,7 +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`. 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. It allocates and shapes text: call it from the owner goroutine when opening a surface, not on every frame. It works before `Resize` and returns an error for a closed Renderer, a nil model or view, or a `maxWidth` that is not finite and above zero.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).
11 - `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.
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 - `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`.
13 - `Close` cancels pending pastes and frees GPU resources.13 - `Close` cancels pending pastes and frees GPU resources.
M internal/ui/renderer.go +41 -16
@@ -286,17 +286,26 @@ func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (
286286 // Measure returns the logical size, in pixels rounded up, that view needs for
287287 // model: its natural width, at most maxWidth, and the height the content takes
288288 // at that width (text wraps at maxWidth). Use it to size a surface before
289-// creating it.
289+// creating it. The size includes the root's margins.
290290 //
291291 // The view is built in a temporary runtime that shares only this Renderer's
292-// styles and text engine (caches: a Measure ages their entries by one frame,
293-// so a later Render may recompute some, with identical results); hover, focus, scroll offsets, editor state, queued input, the Wake channel
294-// and the target are neither read nor changed, nothing is drawn, and the view
295-// sees no events. Call it from the owner goroutine, before or after Resize. It
296-// allocates and shapes text: use it when opening a surface, not on every frame.
297-// It returns an error when the Renderer is closed, model or view is nil, or
298-// maxWidth is not a finite number above zero. A view that declares no root
299-// measures 0 by 0.
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.
300309 func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) {
301310 if r.closed {
302311 return 0, 0, errClosed
@@ -308,23 +317,37 @@ func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth floa
308317 return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero")
309318 }
310319 const tall = 1 << 20
311- tmp := newRuntime()
312- tmp.styles, tmp.textEngine = r.rt.styles, r.rt.textEngine
313- tmp.width, tmp.height = maxWidth, tall
314- tmp.redraw = true
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+ }
315325 if !tmp.Build(func(f *Frame) { view(f, model) }) {
316- return 0, 0, errors.New("nefergui: Measure could not lay out the view")
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")
317330 }
318331 if tmp.committed == nil {
319332 return 0, 0, nil
320333 }
321334 root := tmp.layoutTree(tmp.committed)
335+ if root.Style != nil {
336+ st := layout.IndefiniteHeights(*root.Style)
337+ root.Style = &st
338+ }
322339 opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine}
323340 natural, err := layout.Natural(root, opts)
324341 if err != nil {
325342 return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
326343 }
327- w := math.Min(natural.W, maxWidth)
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)
328351 opts.Width = w
329352 out, err := layout.Layout(root, opts)
330353 if err != nil {
@@ -333,7 +356,9 @@ func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth floa
333356 if out.Tree == nil {
334357 return 0, 0, nil
335358 }
336- return math.Ceil(w), math.Ceil(out.Tree.Border.H), nil
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
337362 }
338363
339364 // export copies the target's frame description into out and adds the parts
@@ -286,17 +286,26 @@ func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) (
286 // Measure returns the logical size, in pixels rounded up, that view needs for286 // 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 takes287 // 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 before288 // at that width (text wraps at maxWidth). Use it to size a surface before
289-// creating it.289+// creating it. The size includes the root's margins.
290 //290 //
291 // The view is built in a temporary runtime that shares only this Renderer's291 // The view is built in a temporary runtime that shares only this Renderer's
292-// styles and text engine (caches: a Measure ages their entries by one frame,292+// styles and text engine. Those caches only grow during Measure: it does not
293-// so a later Render may recompute some, with identical results); hover, focus, scroll offsets, editor state, queued input, the Wake channel293+// age their entries, so a later Render finds its own entries intact. Hover,
294-// and the target are neither read nor changed, nothing is drawn, and the view294+// focus, scroll offsets, editor state, queued input, the Wake channel and the
295-// sees no events. Call it from the owner goroutine, before or after Resize. It295+// target are neither read nor changed, nothing is drawn, and the view sees no
296-// allocates and shapes text: use it when opening a surface, not on every frame.296+// events.
297-// It returns an error when the Renderer is closed, model or view is nil, or297+//
298-// maxWidth is not a finite number above zero. A view that declares no root298+// There is no surface yet, so the height is indefinite: inside the view
299-// measures 0 by 0.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.
300 func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) {309 func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) {
301 if r.closed {310 if r.closed {
302 return 0, 0, errClosed311 return 0, 0, errClosed
@@ -308,23 +317,37 @@ func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth floa
308 return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero")317 return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero")
309 }318 }
310 const tall = 1 << 20319 const tall = 1 << 20
311- tmp := newRuntime()320+ tmp := &runtime{
312- tmp.styles, tmp.textEngine = r.rt.styles, r.rt.textEngine321+ wake: make(chan struct{}, 1), styles: r.rt.styles, textEngine: r.rt.textEngine,
313- tmp.width, tmp.height = maxWidth, tall322+ width: maxWidth, height: tall, scale: 1, redraw: true,
314- tmp.redraw = true323+ edits: make(map[string]*edit.State), measuring: true,
324+ }
315 if !tmp.Build(func(f *Frame) { view(f, model) }) {325 if !tmp.Build(func(f *Frame) { view(f, model) }) {
316- return 0, 0, errors.New("nefergui: Measure could not lay out the view")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")
317 }330 }
318 if tmp.committed == nil {331 if tmp.committed == nil {
319 return 0, 0, nil332 return 0, 0, nil
320 }333 }
321 root := tmp.layoutTree(tmp.committed)334 root := tmp.layoutTree(tmp.committed)
335+ if root.Style != nil {
336+ st := layout.IndefiniteHeights(*root.Style)
337+ root.Style = &st
338+ }
322 opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine}339 opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine}
323 natural, err := layout.Natural(root, opts)340 natural, err := layout.Natural(root, opts)
324 if err != nil {341 if err != nil {
325 return 0, 0, fmt.Errorf("nefergui: measure: %w", err)342 return 0, 0, fmt.Errorf("nefergui: measure: %w", err)
326 }343 }
327- w := math.Min(natural.W, maxWidth)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)
328 opts.Width = w351 opts.Width = w
329 out, err := layout.Layout(root, opts)352 out, err := layout.Layout(root, opts)
330 if err != nil {353 if err != nil {
@@ -333,7 +356,9 @@ func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth floa
333 if out.Tree == nil {356 if out.Tree == nil {
334 return 0, 0, nil357 return 0, 0, nil
335 }358 }
336- return math.Ceil(w), math.Ceil(out.Tree.Border.H), nil359+ 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
337 }362 }
338 363
339 // 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
M internal/ui/renderer_test.go +163 -0
@@ -6,8 +6,10 @@ import (
66 "errors"
77 "fmt"
88 "math"
9+ "strings"
910 "testing"
1011
12+ "github.com/bnema/nefergui/internal/css"
1113 "github.com/bnema/nefergui/internal/layout"
1214 "github.com/bnema/nefergui/internal/presentation/session"
1315 "github.com/bnema/nefergui/internal/text"
@@ -490,3 +492,164 @@ func TestRendererMeasureChangesNoState(t *testing.T) {
490492 t.Fatalf("hover/focus lost after Render: %+v", rt.state)
491493 }
492494 }
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+}
@@ -6,8 +6,10 @@ import (
6 "errors"6 "errors"
7 "fmt"7 "fmt"
8 "math"8 "math"
9+ "strings"
9 "testing"10 "testing"
10 11
12+ "github.com/bnema/nefergui/internal/css"
11 "github.com/bnema/nefergui/internal/layout"13 "github.com/bnema/nefergui/internal/layout"
12 "github.com/bnema/nefergui/internal/presentation/session"14 "github.com/bnema/nefergui/internal/presentation/session"
13 "github.com/bnema/nefergui/internal/text"15 "github.com/bnema/nefergui/internal/text"
@@ -490,3 +492,164 @@ func TestRendererMeasureChangesNoState(t *testing.T) {
490 t.Fatalf("hover/focus lost after Render: %+v", rt.state)492 t.Fatalf("hover/focus lost after Render: %+v", rt.state)
491 }493 }
492 }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

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