feat(ui): Measure
Renderer.Measure returns the natural logical size of a view, wrapped at maxWidth, from a temporary runtime so no renderer state changes.
- commit
- 48b428b9bf335dd397c262097597a73063729c11
- author
- brice · · 2026-10-09 03:37 UTC
- parent
- db490c8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +195 -0
M docs/runtime.md +1 -0
| @@ -7,6 +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 | 11 | - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable. | |
| 11 | 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 | 13 | - `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`. 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 | - `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/ui/renderer.go +53 -0
| @@ -283,6 +283,59 @@ func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) ( | |||
| 283 | 283 | 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. | |
| 290 | + | // | |
| 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. | |
| 300 | + | func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) { | |
| 301 | + | if r.closed { | |
| 302 | + | return 0, 0, errClosed | |
| 303 | + | } | |
| 304 | + | if model == nil || view == nil { | |
| 305 | + | return 0, 0, errors.New("nefergui: nil model or view") | |
| 306 | + | } | |
| 307 | + | if math.IsNaN(maxWidth) || math.IsInf(maxWidth, 0) || maxWidth <= 0 { | |
| 308 | + | return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero") | |
| 309 | + | } | |
| 310 | + | 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 | |
| 315 | + | if !tmp.Build(func(f *Frame) { view(f, model) }) { | |
| 316 | + | return 0, 0, errors.New("nefergui: Measure could not lay out the view") | |
| 317 | + | } | |
| 318 | + | if tmp.committed == nil { | |
| 319 | + | return 0, 0, nil | |
| 320 | + | } | |
| 321 | + | root := tmp.layoutTree(tmp.committed) | |
| 322 | + | opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine} | |
| 323 | + | natural, err := layout.Natural(root, opts) | |
| 324 | + | if err != nil { | |
| 325 | + | return 0, 0, fmt.Errorf("nefergui: measure: %w", err) | |
| 326 | + | } | |
| 327 | + | w := math.Min(natural.W, maxWidth) | |
| 328 | + | opts.Width = w | |
| 329 | + | out, err := layout.Layout(root, opts) | |
| 330 | + | if err != nil { | |
| 331 | + | return 0, 0, fmt.Errorf("nefergui: measure: %w", err) | |
| 332 | + | } | |
| 333 | + | if out.Tree == nil { | |
| 334 | + | return 0, 0, nil | |
| 335 | + | } | |
| 336 | + | return math.Ceil(w), math.Ceil(out.Tree.Border.H), nil | |
| 337 | + | } | |
| 338 | + | ||
| 286 | 339 | // export copies the target's frame description into out and adds the parts | |
| 287 | 340 | // derived from the UI: damage, cursor and the staged input region. | |
| 288 | 341 | func (r *Renderer) export(out *Output) { | |
| @@ -283,6 +283,59 @@ func (r *Renderer) Render[T any](out *Output, model *T, view func(*Frame, *T)) ( | |||||
| 283 | return true, nil | 283 | 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. | |||
| 290 | + | // | |||
| 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. | |||
| 300 | + | func (r *Renderer) Measure[T any](model *T, view func(*Frame, *T), maxWidth float64) (width, height float64, err error) { | |||
| 301 | + | if r.closed { | |||
| 302 | + | return 0, 0, errClosed | |||
| 303 | + | } | |||
| 304 | + | if model == nil || view == nil { | |||
| 305 | + | return 0, 0, errors.New("nefergui: nil model or view") | |||
| 306 | + | } | |||
| 307 | + | if math.IsNaN(maxWidth) || math.IsInf(maxWidth, 0) || maxWidth <= 0 { | |||
| 308 | + | return 0, 0, errors.New("nefergui: Measure needs a finite maxWidth above zero") | |||
| 309 | + | } | |||
| 310 | + | 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 | |||
| 315 | + | if !tmp.Build(func(f *Frame) { view(f, model) }) { | |||
| 316 | + | return 0, 0, errors.New("nefergui: Measure could not lay out the view") | |||
| 317 | + | } | |||
| 318 | + | if tmp.committed == nil { | |||
| 319 | + | return 0, 0, nil | |||
| 320 | + | } | |||
| 321 | + | root := tmp.layoutTree(tmp.committed) | |||
| 322 | + | opts := layout.Options{Width: maxWidth, Height: tall, TextEngine: tmp.textEngine} | |||
| 323 | + | natural, err := layout.Natural(root, opts) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return 0, 0, fmt.Errorf("nefergui: measure: %w", err) | |||
| 326 | + | } | |||
| 327 | + | w := math.Min(natural.W, maxWidth) | |||
| 328 | + | opts.Width = w | |||
| 329 | + | out, err := layout.Layout(root, opts) | |||
| 330 | + | if err != nil { | |||
| 331 | + | return 0, 0, fmt.Errorf("nefergui: measure: %w", err) | |||
| 332 | + | } | |||
| 333 | + | if out.Tree == nil { | |||
| 334 | + | return 0, 0, nil | |||
| 335 | + | } | |||
| 336 | + | return math.Ceil(w), math.Ceil(out.Tree.Border.H), nil | |||
| 337 | + | } | |||
| 338 | + | ||||
| 286 | // export copies the target's frame description into out and adds the parts | 339 | // 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. | 340 | // derived from the UI: damage, cursor and the staged input region. | ||
| 288 | func (r *Renderer) export(out *Output) { | 341 | func (r *Renderer) export(out *Output) { | ||
M internal/ui/renderer_test.go +136 -0
| @@ -5,6 +5,7 @@ package ui | |||
| 5 | 5 | import ( | |
| 6 | 6 | "errors" | |
| 7 | 7 | "fmt" | |
| 8 | + | "math" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/nefergui/internal/layout" | |
| @@ -354,3 +355,138 @@ func TestRendererLockModifiersDoNotChangeInput(t *testing.T) { | |||
| 354 | 355 | }) | |
| 355 | 356 | } | |
| 356 | 357 | } | |
| 358 | + | ||
| 359 | + | type measureModel struct{ title, body string } | |
| 360 | + | ||
| 361 | + | func measureView(f *Frame, m *measureModel) { | |
| 362 | + | root := f.Root(Inline("padding:4px")) | |
| 363 | + | root.Text(m.title) | |
| 364 | + | root.Text(m.body) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | func TestRendererMeasureNaturalSizeBeforeResize(t *testing.T) { | |
| 368 | + | r, _ := newTestRenderer(t) // the mock target has no expectations: any target call fails | |
| 369 | + | m := measureModel{title: "Title", body: "short"} | |
| 370 | + | w, h, err := r.Measure(&m, measureView, 400) | |
| 371 | + | if err != nil { | |
| 372 | + | t.Fatal(err) | |
| 373 | + | } | |
| 374 | + | if w <= 8 || w >= 400 || h <= 8 || w != float64(int(w)) || h != float64(int(h)) { | |
| 375 | + | t.Fatalf("size %g x %g", w, h) | |
| 376 | + | } | |
| 377 | + | single := h | |
| 378 | + | m.body = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau" | |
| 379 | + | w, h, err = r.Measure(&m, measureView, 100) | |
| 380 | + | if err != nil { | |
| 381 | + | t.Fatal(err) | |
| 382 | + | } | |
| 383 | + | if w != 100 || h <= single+10 { | |
| 384 | + | t.Fatalf("wrapped size %g x %g, one-line height %g", w, h, single) | |
| 385 | + | } | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestRendererMeasureValidation(t *testing.T) { | |
| 389 | + | r, _ := newTestRenderer(t) | |
| 390 | + | m := measureModel{} | |
| 391 | + | for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1)} { | |
| 392 | + | if _, _, err := r.Measure(&m, measureView, bad); err == nil { | |
| 393 | + | t.Errorf("maxWidth %v accepted", bad) | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | if _, _, err := r.Measure[measureModel](nil, measureView, 100); err == nil { | |
| 397 | + | t.Error("nil model accepted") | |
| 398 | + | } | |
| 399 | + | if _, _, err := r.Measure(&m, nil, 100); err == nil { | |
| 400 | + | t.Error("nil view accepted") | |
| 401 | + | } | |
| 402 | + | if w, h, err := r.Measure(&m, func(*Frame, *measureModel) {}, 100); err != nil || w != 0 || h != 0 { | |
| 403 | + | t.Errorf("view without root: %g x %g %v", w, h, err) | |
| 404 | + | } | |
| 405 | + | tg := r.target.(*mocktarget) | |
| 406 | + | tg.EXPECT().Close().Return(nil).Once() | |
| 407 | + | _ = r.Close() | |
| 408 | + | if _, _, err := r.Measure(&m, measureView, 100); err == nil { | |
| 409 | + | t.Error("closed renderer accepted") | |
| 410 | + | } | |
| 411 | + | } | |
| 412 | + | ||
| 413 | + | func TestRendererMeasureChangesNoState(t *testing.T) { | |
| 414 | + | r, tg := newTestRenderer(t) | |
| 415 | + | type model struct { | |
| 416 | + | clicks int | |
| 417 | + | text string | |
| 418 | + | } | |
| 419 | + | m := model{text: "edit me"} | |
| 420 | + | view := func(f *Frame, m *model) { | |
| 421 | + | root := f.Root() | |
| 422 | + | if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() { | |
| 423 | + | m.clicks++ | |
| 424 | + | } | |
| 425 | + | root.Input("field", &m.text, Key("field")) | |
| 426 | + | } | |
| 427 | + | var out Output | |
| 428 | + | tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once() | |
| 429 | + | r.Resize(200, 100, 1) | |
| 430 | + | drawOK(tg, nil).Maybe() | |
| 431 | + | render := func() { | |
| 432 | + | t.Helper() | |
| 433 | + | for i := 0; i < 3; i++ { | |
| 434 | + | if _, err := r.Render(&out, &m, view); err != nil { | |
| 435 | + | t.Fatal(err) | |
| 436 | + | } | |
| 437 | + | } | |
| 438 | + | } | |
| 439 | + | render() | |
| 440 | + | r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) | |
| 441 | + | r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true}) // Tab: focus the button | |
| 442 | + | render() | |
| 443 | + | draws := len(tg.Calls) | |
| 444 | + | rt := r.rt | |
| 445 | + | if rt.state.hover == nil || rt.state.focus == nil { | |
| 446 | + | t.Fatalf("setup: hover=%v focus=%v", rt.state.hover, rt.state.focus) | |
| 447 | + | } | |
| 448 | + | // Input queued but not yet built must survive Measure untouched. | |
| 449 | + | r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true}) | |
| 450 | + | r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) | |
| 451 | + | before := struct { | |
| 452 | + | state interactionState | |
| 453 | + | committed *element | |
| 454 | + | generation uint64 | |
| 455 | + | edits, pending, wake int | |
| 456 | + | styles any | |
| 457 | + | engine any | |
| 458 | + | }{rt.state, rt.committed, rt.generation, len(rt.edits), len(rt.pending), len(rt.wake), rt.styles, rt.textEngine} | |
| 459 | + | if before.pending == 0 || before.wake == 0 { | |
| 460 | + | t.Fatalf("setup: pending=%d wake=%d", before.pending, before.wake) | |
| 461 | + | } | |
| 462 | + | ||
| 463 | + | if _, _, err := r.Measure(&m, view, 300); err != nil { | |
| 464 | + | t.Fatal(err) | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | 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 { | |
| 468 | + | t.Fatalf("interaction state changed: %+v -> %+v", before.state, rt.state) | |
| 469 | + | } | |
| 470 | + | if rt.committed != before.committed || rt.generation != before.generation { | |
| 471 | + | t.Fatal("committed tree or generation changed") | |
| 472 | + | } | |
| 473 | + | if len(rt.edits) != before.edits || rt.styles != before.styles || rt.textEngine != before.engine { | |
| 474 | + | t.Fatal("runtime parts changed") | |
| 475 | + | } | |
| 476 | + | if len(rt.pending) != before.pending || len(rt.wake) != before.wake { | |
| 477 | + | t.Fatalf("pending %d -> %d, wake %d -> %d", before.pending, len(rt.pending), before.wake, len(rt.wake)) | |
| 478 | + | } | |
| 479 | + | if m.clicks != 0 || m.text != "edit me" { | |
| 480 | + | t.Fatalf("model changed: %+v", m) | |
| 481 | + | } | |
| 482 | + | if len(tg.Calls) != draws { | |
| 483 | + | t.Fatalf("target called by Measure: %d -> %d calls", draws, len(tg.Calls)) | |
| 484 | + | } | |
| 485 | + | render() | |
| 486 | + | if m.clicks != 1 { | |
| 487 | + | t.Fatalf("the click queued before Measure was lost: clicks=%d", m.clicks) | |
| 488 | + | } | |
| 489 | + | if rt.state.hover == nil || rt.state.focus == nil { | |
| 490 | + | t.Fatalf("hover/focus lost after Render: %+v", rt.state) | |
| 491 | + | } | |
| 492 | + | } | |
| @@ -5,6 +5,7 @@ package ui | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | + | "math" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/nefergui/internal/layout" | 11 | "github.com/bnema/nefergui/internal/layout" | ||
| @@ -354,3 +355,138 @@ func TestRendererLockModifiersDoNotChangeInput(t *testing.T) { | |||||
| 354 | }) | 355 | }) | ||
| 355 | } | 356 | } | ||
| 356 | } | 357 | } | ||
| 358 | + | ||||
| 359 | + | type measureModel struct{ title, body string } | |||
| 360 | + | ||||
| 361 | + | func measureView(f *Frame, m *measureModel) { | |||
| 362 | + | root := f.Root(Inline("padding:4px")) | |||
| 363 | + | root.Text(m.title) | |||
| 364 | + | root.Text(m.body) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | func TestRendererMeasureNaturalSizeBeforeResize(t *testing.T) { | |||
| 368 | + | r, _ := newTestRenderer(t) // the mock target has no expectations: any target call fails | |||
| 369 | + | m := measureModel{title: "Title", body: "short"} | |||
| 370 | + | w, h, err := r.Measure(&m, measureView, 400) | |||
| 371 | + | if err != nil { | |||
| 372 | + | t.Fatal(err) | |||
| 373 | + | } | |||
| 374 | + | if w <= 8 || w >= 400 || h <= 8 || w != float64(int(w)) || h != float64(int(h)) { | |||
| 375 | + | t.Fatalf("size %g x %g", w, h) | |||
| 376 | + | } | |||
| 377 | + | single := h | |||
| 378 | + | m.body = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau" | |||
| 379 | + | w, h, err = r.Measure(&m, measureView, 100) | |||
| 380 | + | if err != nil { | |||
| 381 | + | t.Fatal(err) | |||
| 382 | + | } | |||
| 383 | + | if w != 100 || h <= single+10 { | |||
| 384 | + | t.Fatalf("wrapped size %g x %g, one-line height %g", w, h, single) | |||
| 385 | + | } | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestRendererMeasureValidation(t *testing.T) { | |||
| 389 | + | r, _ := newTestRenderer(t) | |||
| 390 | + | m := measureModel{} | |||
| 391 | + | for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1)} { | |||
| 392 | + | if _, _, err := r.Measure(&m, measureView, bad); err == nil { | |||
| 393 | + | t.Errorf("maxWidth %v accepted", bad) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | if _, _, err := r.Measure[measureModel](nil, measureView, 100); err == nil { | |||
| 397 | + | t.Error("nil model accepted") | |||
| 398 | + | } | |||
| 399 | + | if _, _, err := r.Measure(&m, nil, 100); err == nil { | |||
| 400 | + | t.Error("nil view accepted") | |||
| 401 | + | } | |||
| 402 | + | if w, h, err := r.Measure(&m, func(*Frame, *measureModel) {}, 100); err != nil || w != 0 || h != 0 { | |||
| 403 | + | t.Errorf("view without root: %g x %g %v", w, h, err) | |||
| 404 | + | } | |||
| 405 | + | tg := r.target.(*mocktarget) | |||
| 406 | + | tg.EXPECT().Close().Return(nil).Once() | |||
| 407 | + | _ = r.Close() | |||
| 408 | + | if _, _, err := r.Measure(&m, measureView, 100); err == nil { | |||
| 409 | + | t.Error("closed renderer accepted") | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | func TestRendererMeasureChangesNoState(t *testing.T) { | |||
| 414 | + | r, tg := newTestRenderer(t) | |||
| 415 | + | type model struct { | |||
| 416 | + | clicks int | |||
| 417 | + | text string | |||
| 418 | + | } | |||
| 419 | + | m := model{text: "edit me"} | |||
| 420 | + | view := func(f *Frame, m *model) { | |||
| 421 | + | root := f.Root() | |||
| 422 | + | if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() { | |||
| 423 | + | m.clicks++ | |||
| 424 | + | } | |||
| 425 | + | root.Input("field", &m.text, Key("field")) | |||
| 426 | + | } | |||
| 427 | + | var out Output | |||
| 428 | + | tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once() | |||
| 429 | + | r.Resize(200, 100, 1) | |||
| 430 | + | drawOK(tg, nil).Maybe() | |||
| 431 | + | render := func() { | |||
| 432 | + | t.Helper() | |||
| 433 | + | for i := 0; i < 3; i++ { | |||
| 434 | + | if _, err := r.Render(&out, &m, view); err != nil { | |||
| 435 | + | t.Fatal(err) | |||
| 436 | + | } | |||
| 437 | + | } | |||
| 438 | + | } | |||
| 439 | + | render() | |||
| 440 | + | r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) | |||
| 441 | + | r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true}) // Tab: focus the button | |||
| 442 | + | render() | |||
| 443 | + | draws := len(tg.Calls) | |||
| 444 | + | rt := r.rt | |||
| 445 | + | if rt.state.hover == nil || rt.state.focus == nil { | |||
| 446 | + | t.Fatalf("setup: hover=%v focus=%v", rt.state.hover, rt.state.focus) | |||
| 447 | + | } | |||
| 448 | + | // Input queued but not yet built must survive Measure untouched. | |||
| 449 | + | r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true}) | |||
| 450 | + | r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) | |||
| 451 | + | before := struct { | |||
| 452 | + | state interactionState | |||
| 453 | + | committed *element | |||
| 454 | + | generation uint64 | |||
| 455 | + | edits, pending, wake int | |||
| 456 | + | styles any | |||
| 457 | + | engine any | |||
| 458 | + | }{rt.state, rt.committed, rt.generation, len(rt.edits), len(rt.pending), len(rt.wake), rt.styles, rt.textEngine} | |||
| 459 | + | if before.pending == 0 || before.wake == 0 { | |||
| 460 | + | t.Fatalf("setup: pending=%d wake=%d", before.pending, before.wake) | |||
| 461 | + | } | |||
| 462 | + | ||||
| 463 | + | if _, _, err := r.Measure(&m, view, 300); err != nil { | |||
| 464 | + | t.Fatal(err) | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | 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 { | |||
| 468 | + | t.Fatalf("interaction state changed: %+v -> %+v", before.state, rt.state) | |||
| 469 | + | } | |||
| 470 | + | if rt.committed != before.committed || rt.generation != before.generation { | |||
| 471 | + | t.Fatal("committed tree or generation changed") | |||
| 472 | + | } | |||
| 473 | + | if len(rt.edits) != before.edits || rt.styles != before.styles || rt.textEngine != before.engine { | |||
| 474 | + | t.Fatal("runtime parts changed") | |||
| 475 | + | } | |||
| 476 | + | if len(rt.pending) != before.pending || len(rt.wake) != before.wake { | |||
| 477 | + | t.Fatalf("pending %d -> %d, wake %d -> %d", before.pending, len(rt.pending), before.wake, len(rt.wake)) | |||
| 478 | + | } | |||
| 479 | + | if m.clicks != 0 || m.text != "edit me" { | |||
| 480 | + | t.Fatalf("model changed: %+v", m) | |||
| 481 | + | } | |||
| 482 | + | if len(tg.Calls) != draws { | |||
| 483 | + | t.Fatalf("target called by Measure: %d -> %d calls", draws, len(tg.Calls)) | |||
| 484 | + | } | |||
| 485 | + | render() | |||
| 486 | + | if m.clicks != 1 { | |||
| 487 | + | t.Fatalf("the click queued before Measure was lost: clicks=%d", m.clicks) | |||
| 488 | + | } | |||
| 489 | + | if rt.state.hover == nil || rt.state.focus == nil { | |||
| 490 | + | t.Fatalf("hover/focus lost after Render: %+v", rt.state) | |||
| 491 | + | } | |||
| 492 | + | } | |||
M renderer.go +5 -0
| @@ -16,6 +16,11 @@ import "github.com/bnema/nefergui/internal/ui" | |||
| 16 | 16 | // Render is generic over the model type, so it is a generic method: | |
| 17 | 17 | // | |
| 18 | 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 | 24 | type Renderer = ui.Renderer | |
| 20 | 25 | ||
| 21 | 26 | // 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.Renderer | 24 | type Renderer = ui.Renderer | ||
| 20 | 25 | ||||
| 21 | // RendererConfig configures NewRenderer. MainDevice and Formats come from the | 26 | // RendererConfig configures NewRenderer. MainDevice and Formats come from the | ||