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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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: | |||
| 7 | 7 | - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`. | |
| 8 | 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 | 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 | 11 | - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable. | |
| 12 | 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 | 13 | - `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)) ( | |||
| 286 | 286 | // Measure returns the logical size, in pixels rounded up, that view needs for | |
| 287 | 287 | // model: its natural width, at most maxWidth, and the height the content takes | |
| 288 | 288 | // 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 | 291 | // 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. | |
| 300 | 309 | func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) { | |
| 301 | 310 | if r.closed { | |
| 302 | 311 | return 0, 0, errClosed | |
| @@ -308,23 +317,37 @@ func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth floa | |||
| 308 | 317 | return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero") | |
| 309 | 318 | } | |
| 310 | 319 | 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 | + | } | |
| 315 | 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 | 331 | if tmp.committed == nil { | |
| 319 | 332 | return 0, 0, nil | |
| 320 | 333 | } | |
| 321 | 334 | root := tmp.layoutTree(tmp.committed) | |
| 335 | + | if root.Style != nil { | |
| 336 | + | st := layout.IndefiniteHeights(*root.Style) | |
| 337 | + | root.Style = &st | |
| 338 | + | } | |
| 322 | 339 | opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine} | |
| 323 | 340 | natural, err := layout.Natural(root, opts) | |
| 324 | 341 | if err != nil { | |
| 325 | 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 | 351 | opts.Width = w | |
| 329 | 352 | out, err := layout.Layout(root, opts) | |
| 330 | 353 | if err != nil { | |
| @@ -333,7 +356,9 @@ func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth floa | |||
| 333 | 356 | if out.Tree == nil { | |
| 334 | 357 | return 0, 0, nil | |
| 335 | 358 | } | |
| 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 | |
| 337 | 362 | } | |
| 338 | 363 | ||
| 339 | 364 | // 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 for | 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 | 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 | 288 | // 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's | 291 | // 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 channel | 293 | + | // 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 view | 294 | + | // 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. It | 295 | + | // 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, or | 297 | + | // |
| 298 | - | // maxWidth is not a finite number above zero. A view that declares no root | 298 | + | // 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, errClosed | 311 | 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 << 20 | 319 | const tall = 1 << 20 | ||
| 311 | - | tmp := newRuntime() | 320 | + | tmp := &runtime{ |
| 312 | - | tmp.styles, tmp.textEngine = r.rt.styles, r.rt.textEngine | 321 | + | wake: make(chan struct{}, 1), styles: r.rt.styles, textEngine: r.rt.textEngine, |
| 313 | - | tmp.width, tmp.height = maxWidth, tall | 322 | + | width: maxWidth, height: tall, scale: 1, redraw: true, |
| 314 | - | tmp.redraw = true | 323 | + | 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, nil | 332 | 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 = w | 351 | 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, nil | 357 | return 0, 0, nil | ||
| 335 | } | 358 | } | ||
| 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 | |||
| 337 | } | 362 | } | ||
| 338 | 363 | ||||
| 339 | // export copies the target's frame description into out and adds the parts | 364 | // 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 ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "math" | |
| 9 | + | "strings" | |
| 9 | 10 | "testing" | |
| 10 | 11 | ||
| 12 | + | "github.com/bnema/nefergui/internal/css" | |
| 11 | 13 | "github.com/bnema/nefergui/internal/layout" | |
| 12 | 14 | "github.com/bnema/nefergui/internal/presentation/session" | |
| 13 | 15 | "github.com/bnema/nefergui/internal/text" | |
| @@ -490,3 +492,164 @@ func TestRendererMeasureChangesNoState(t *testing.T) { | |||
| 490 | 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 | + | } | |
| @@ -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 { | |||
| 50 | 50 | redraw bool | |
| 51 | 51 | inputRects []Rect | |
| 52 | 52 | 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 | 62 | func newRuntime() *runtime { | |
| @@ -134,9 +141,11 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 134 | 141 | f := &r.frame | |
| 135 | 142 | defer func() { | |
| 136 | 143 | 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 | + | } | |
| 140 | 149 | } | |
| 141 | 150 | // A panicking view is not committed; the next build may retry. | |
| 142 | 151 | if recovered := recover(); recovered != nil { | |
| @@ -148,6 +157,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 148 | 157 | r.mu.Lock() | |
| 149 | 158 | // State can change while the view runs. Schedule another frame instead of | |
| 150 | 159 | // overwriting that newer input with this frame's snapshot. | |
| 160 | + | r.layoutErr = nil | |
| 151 | 161 | if f.root != nil { | |
| 152 | 162 | f.refresh(f.root) | |
| 153 | 163 | input := r.layoutTree(f.root) | |
| @@ -157,6 +167,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 157 | 167 | opts := layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine, Arena: &r.layouts[nextLayout]} | |
| 158 | 168 | out, err := layout.Layout(input, opts) | |
| 159 | 169 | if err != nil { | |
| 170 | + | r.layoutErr = err | |
| 160 | 171 | r.mu.Unlock() | |
| 161 | 172 | r.Redraw() | |
| 162 | 173 | return false | |
| @@ -168,6 +179,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 168 | 179 | input = r.layoutTree(f.root) | |
| 169 | 180 | out, err = layout.Layout(input, opts) | |
| 170 | 181 | if err != nil { | |
| 182 | + | r.layoutErr = err | |
| 171 | 183 | r.mu.Unlock() | |
| 172 | 184 | r.Redraw() | |
| 173 | 185 | return false | |
| @@ -50,6 +50,13 @@ type runtime struct { | |||||
| 50 | redraw bool | 50 | redraw bool | ||
| 51 | inputRects []Rect | 51 | inputRects []Rect | ||
| 52 | inputNil, inputStaged bool | 52 | 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.frame | 141 | f := &r.frame | ||
| 135 | defer func() { | 142 | defer func() { | ||
| 136 | f.active = false | 143 | 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 of | 158 | // 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 false | 173 | 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 false | 185 | return false | ||