detached 48b428b

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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:
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.
1011 - `Released(buffer)` returns a buffer to the pool when its `ReleaseFD` eventfd becomes readable.
1112 - `Pending()` reports a built frame waiting for the GPU rather than the compositor. No event announces this: arm a short timer (about 2 ms) and call `Render` again. `Wake()` is signaled when another goroutine, for example a finished paste, needs a `Render`.
1213 - `Close` cancels pending pastes and frees GPU resources.
@@ -7,6 +7,7 @@ A client loop forwards events and presents frames:
7 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.7 - `Resize(width, height, scale)` sets the logical size and scale; invalid values are ignored. Nothing is drawn before the first `Resize`.
8 - `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.8 - `Input(*Input)` routes one pointer, key or focus event and reports whether a redraw is needed. Coordinates are logical surface pixels. `Keysym` carries xkb keysym values and `Text` the UTF-8 text of the key; `Text` is valid only during the call. `Modifiers` is a bit set of `ModShift`, `ModCtrl`, `ModAlt`, `ModSuper` (held) and `ModCapsLock`, `ModNumLock` (latched). Only Shift and Ctrl affect shortcuts and editing; lock bits are ignored, and views do not see modifiers, so an application that shows a lock indicator keeps the lock bits of `Input.Modifiers` in its model. `InputReset` drops held state after a lost input queue. `Invalidate` forces a rebuild.
9 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.9 - `Render(&out, &model, view)` builds the view when input or a redraw request is queued and draws it into a free buffer. It returns false, without touching the GPU, when nothing changed or no buffer is free. When it returns true, `out` describes the frame in physical pixels: DMA-BUF `Planes`, `FourCC`, `Modifier`, acquire and release timelines and points, `Damage`, `Cursor`, and `InputRects` with `InputRectsChanged`.
10+- `Measure(&model, view, maxWidth)` returns the natural `width` (at most `maxWidth`) and `height` of the view in logical pixels, rounded up, with text wrapped at `maxWidth`. 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)) (
283283 return true, nil
284284 }
285285
286+// Measure returns the logical size, in pixels rounded up, that view needs for
287+// model: its natural width, at most maxWidth, and the height the content takes
288+// at that width (text wraps at maxWidth). Use it to size a surface before
289+// creating it.
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+
286339 // export copies the target's frame description into out and adds the parts
287340 // derived from the UI: damage, cursor and the staged input region.
288341 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, nil283 return true, nil
284 }284 }
285 285
286+// Measure returns the logical size, in pixels rounded up, that view needs for
287+// model: its natural width, at most maxWidth, and the height the content takes
288+// at that width (text wraps at maxWidth). Use it to size a surface before
289+// creating it.
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 parts339 // 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
55 import (
66 "errors"
77 "fmt"
8+ "math"
89 "testing"
910
1011 "github.com/bnema/nefergui/internal/layout"
@@ -354,3 +355,138 @@ func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
354355 })
355356 }
356357 }
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"
1616 // Render is generic over the model type, so it is a generic method:
1717 //
1818 // ok, err := r.Render(&out, &model, view)
19+//
20+// Measure sizes a view before a surface exists (owner goroutine, not for every
21+// frame); it changes no Renderer state:
22+//
23+// width, height, err := r.Measure(&model, view, maxWidth)
1924 type Renderer = ui.Renderer
2025
2126 // RendererConfig configures NewRenderer. MainDevice and Formats come from the
@@ -16,6 +16,11 @@ import "github.com/bnema/nefergui/internal/ui"
16 // Render is generic over the model type, so it is a generic method:16 // Render is generic over the model type, so it is a generic method:
17 //17 //
18 // ok, err := r.Render(&out, &model, view)18 // ok, err := r.Render(&out, &model, view)
19+//
20+// Measure sizes a view before a surface exists (owner goroutine, not for every
21+// frame); it changes no Renderer state:
22+//
23+// width, height, err := r.Measure(&model, view, maxWidth)
19 type Renderer = ui.Renderer24 type Renderer = ui.Renderer
20 25
21 // RendererConfig configures NewRenderer. MainDevice and Formats come from the26 // RendererConfig configures NewRenderer. MainDevice and Formats come from the

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close