main bcc9a45

renderer_test.go RawHistoryFolder

1//go:build linux
2
3package ui
4
5import (
6	"errors"
7	"fmt"
8	"math"
9	"strings"
10	"testing"
11
12	"github.com/bnema/nefergui/internal/css"
13	"github.com/bnema/nefergui/internal/layout"
14	"github.com/bnema/nefergui/internal/presentation/session"
15	"github.com/bnema/nefergui/internal/text"
16	"github.com/stretchr/testify/mock"
17)
18
19type testModel struct {
20	clicks int
21	rects  []Rect
22}
23
24func testView(f *Frame, m *testModel) {
25	root := f.Root()
26	if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
27		m.clicks++
28	}
29	if m.rects != nil {
30		_ = f.SetInputRects(m.rects)
31	}
32}
33
34func newTestRenderer(t *testing.T) (*Renderer, *mocktarget) {
35	t.Helper()
36	tg := newMocktarget(t)
37	r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
38	if err != nil {
39		t.Fatal(err)
40	}
41	return r, tg
42}
43
44func drawOK(tg *mocktarget, fill func(*session.Output)) *mocktarget_Draw_Call {
45	return tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).
46		RunAndReturn(func(_ []layout.Command, _ float64, out *session.Output) (bool, error) {
47			*out = session.Output{Buffer: 1, Width: 100, Height: 50}
48			if fill != nil {
49				fill(out)
50			}
51			return true, nil
52		})
53}
54
55func TestKeysymNames(t *testing.T) {
56	for sym, want := range map[uint32]string{
57		0x20: "space", 0xff09: "tab", 0xfe20: "iso_left_tab", 0xff51: "left", 0xff53: "right",
58		0xff52: "up", 0xff54: "down", 0xff50: "home", 0xff57: "end", 0xff55: "page_up",
59		0xff56: "page_down", 0xff08: "backspace", 0xffff: "delete", 'a': "a", 'A': "a",
60		'c': "c", 'x': "x", 'v': "v", 0xff0d: "return", 0xff8d: "kp_enter", 0xff1b: "escape",
61		'z': "", 0xffe1: "",
62	} {
63		if got := keysymName(sym); got != want {
64			t.Errorf("keysym %#x: got %q want %q", sym, got, want)
65		}
66	}
67}
68
69func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
70	r, tg := newTestRenderer(t)
71	var m testModel
72	var out Output
73	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
74		t.Fatalf("before Resize: ok=%v err=%v", ok, err)
75	}
76	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
77	r.Resize(100, 50, 2)
78	drawOK(tg, nil).Once()
79	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
80		t.Fatalf("first render: ok=%v err=%v", ok, err)
81	}
82	if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
83		t.Fatalf("output: %+v", out)
84	}
85	// Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
86	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
87		t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
88	}
89}
90
91func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
92	r, tg := newTestRenderer(t)
93	var m testModel
94	var out Output
95	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
96	r.Resize(100, 50, 1)
97	tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
98	tg.EXPECT().Waiting().Return(true).Once()
99	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
100		t.Fatalf("no buffer: ok=%v err=%v", ok, err)
101	}
102	if !r.Pending() {
103		t.Fatal("a built frame blocked on the GPU must report Pending")
104	}
105	drawOK(tg, nil).Once()
106	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
107		t.Fatalf("retry: ok=%v err=%v", ok, err)
108	}
109}
110
111func TestRendererDrawError(t *testing.T) {
112	r, tg := newTestRenderer(t)
113	var m testModel
114	var out Output
115	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
116	r.Resize(100, 50, 1)
117	boom := errors.New("boom")
118	tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, boom).Once()
119	if ok, err := r.Render(&out, &m, testView); ok || !errors.Is(err, boom) {
120		t.Fatalf("ok=%v err=%v", ok, err)
121	}
122}
123
124func TestRendererResizeReachesTargetInPhysicalPixels(t *testing.T) {
125	r, tg := newTestRenderer(t)
126	tg.EXPECT().Resize(int32(150), int32(75)).Return().Once()
127	r.Resize(100, 50, 1.5)
128	r.Resize(100, 50, 1.5) // unchanged: no second call
129	r.Resize(0, 50, 1)     // invalid: ignored
130	r.Resize(10, 10, 0)    // invalid: ignored
131}
132
133func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
134	r, tg := newTestRenderer(t)
135	var m testModel
136	var out Output
137	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
138	r.Resize(200, 100, 1)
139	drawOK(tg, nil).Maybe()
140	for i := 0; i < 3; i++ {
141		if _, err := r.Render(&out, &m, testView); err != nil {
142			t.Fatal(err)
143		}
144	}
145	if r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) != true {
146		t.Fatal("hover over a button needs a redraw")
147	}
148	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
149	if !r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) {
150		t.Fatal("release did not request redraw")
151	}
152	for i := 0; i < 3; i++ {
153		if _, err := r.Render(&out, &m, testView); err != nil {
154			t.Fatal(err)
155		}
156	}
157	if m.clicks != 1 {
158		t.Fatalf("clicks=%d", m.clicks)
159	}
160	// Keyboard: Tab focuses, Return activates, text comes from Text.
161	r.Input(&Input{Kind: InputPointerLeave})
162	r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true})
163	r.Input(&Input{Kind: InputKey, Keysym: 0xff0d, Pressed: true})
164	for i := 0; i < 3; i++ {
165		if _, err := r.Render(&out, &m, testView); err != nil {
166			t.Fatal(err)
167		}
168	}
169	if m.clicks != 2 {
170		t.Fatalf("Return did not activate the focused button: clicks=%d", m.clicks)
171	}
172	if r.Input(&Input{Kind: 99}) || r.Input(nil) {
173		t.Fatal("unknown input requested a redraw")
174	}
175	if r.Input(&Input{Kind: InputPointerAxis}) {
176		t.Fatal("zero scroll requested a redraw")
177	}
178}
179
180func TestRendererTextInputUsesTextBytes(t *testing.T) {
181	value := ""
182	view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
183	r, settle := settledRenderer(t, &value, view)
184	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
185	r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
186	settle()
187	text := []byte("é")
188	r.Input(&Input{Kind: InputKey, Keysym: 0xe9, Pressed: true, Text: text})
189	text[0] = 0 // the caller may reuse the buffer after the call
190	settle()
191	if value != "é" {
192		t.Fatalf("value=%q", value)
193	}
194}
195
196func TestRendererOutputCarriesCursorAndInputRects(t *testing.T) {
197	r, tg := newTestRenderer(t)
198	m := testModel{rects: []Rect{{1, 2, 30, 20}}}
199	var out Output
200	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
201	r.Resize(200, 100, 1)
202	drawOK(tg, nil).Maybe()
203	r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) // over the button: pointer cursor
204	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
205		t.Fatalf("ok=%v err=%v", ok, err)
206	}
207	if out.Cursor != CursorPointer || !out.InputRectsChanged || len(out.InputRects) != 1 || out.InputRects[0] != (Rect{1, 2, 30, 20}) {
208		t.Fatalf("output: cursor=%v changed=%v rects=%v", out.Cursor, out.InputRectsChanged, out.InputRects)
209	}
210	// An unchanged region is not reported again.
211	r.Invalidate()
212	ok, err := r.Render(&out, &m, testView)
213	if err != nil {
214		t.Fatal(err)
215	}
216	if ok && out.InputRectsChanged && len(out.InputRects) != 0 {
217		t.Fatalf("region reported twice: %+v", out)
218	}
219	// nil restores the whole surface: changed with no rects.
220	m.rects = nil
221	var nilView = func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }
222	r.Invalidate()
223	if ok, err = r.Render(&out, &m, nilView); err != nil || !ok || !out.InputRectsChanged || out.InputRects != nil {
224		t.Fatalf("nil region: ok=%v err=%v %+v", ok, err, out)
225	}
226}
227
228func TestRendererForwardsReleasedAndClose(t *testing.T) {
229	r, tg := newTestRenderer(t)
230	tg.EXPECT().Released(uint64(3)).Return(nil).Once()
231	if err := r.Released(3); err != nil {
232		t.Fatal(err)
233	}
234	tg.EXPECT().Close().Return(nil).Once()
235	if err := r.Close(); err != nil {
236		t.Fatal(err)
237	}
238	// After Close nothing reaches the target (the mock has no expectation).
239	r.Resize(10, 10, 1)
240	if r.Input(&Input{Kind: InputPointerMotion}) {
241		t.Fatal("Input after Close requested a redraw")
242	}
243	if err := r.Released(3); err == nil {
244		t.Fatal("Released after Close succeeded")
245	}
246	if err := r.Close(); err != nil { // second Close is a no-op
247		t.Fatal(err)
248	}
249	var out Output
250	var m testModel
251	if _, err := r.Render(&out, &m, testView); err == nil {
252		t.Fatal("Render after Close succeeded")
253	}
254}
255
256func TestTextIntAndMasked(t *testing.T) {
257	r := newRuntime()
258	r.Build(func(f *Frame) {
259		root := f.Root()
260		root.TextInt("Count: ", -42)
261		root.Masked(3)
262		root.Masked(0)
263		root.Masked(300)
264	})
265	c := r.committed.children
266	if len(c) != 4 || c[0].text != "Count: -42" || c[1].text != "•••" || c[2].text != "" || len([]rune(c[3].text)) != 300 {
267		t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
268	}
269}
270
271// settledRenderer returns a 200x100 renderer for view and a settle function
272// that renders until queued input has been applied.
273func settledRenderer[T any](t *testing.T, model *T, view func(*Frame, *T)) (*Renderer, func()) {
274	t.Helper()
275	r, tg := newTestRenderer(t)
276	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
277	r.Resize(200, 100, 1)
278	drawOK(tg, nil).Maybe()
279	var out Output
280	settle := func() {
281		for i := 0; i < 3; i++ {
282			if _, err := r.Render(&out, model, view); err != nil {
283				t.Fatal(err)
284			}
285		}
286	}
287	settle()
288	return r, settle
289}
290
291func TestModifierBits(t *testing.T) {
292	// The values match neferclient's, so applications copy the bits as is.
293	if ModShift != 1 || ModCtrl != 2 || ModAlt != 4 || ModSuper != 8 || ModCapsLock != 16 || ModNumLock != 32 {
294		t.Fatal("modifier bit values changed")
295	}
296}
297
298// Caps Lock and Num Lock must not change shortcuts, selection or activation.
299func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
300	inputView := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
301	key := func(r *Renderer, keysym uint32, text string, mods Modifiers) {
302		in := &Input{Kind: InputKey, Keysym: keysym, Pressed: true, Modifiers: mods}
303		if text != "" {
304			in.Text = []byte(text)
305		}
306		r.Input(in)
307	}
308	click := func(r *Renderer, x float64, mods Modifiers) {
309		r.Input(&Input{Kind: InputPointerPress, X: x, Y: 5, Button: 0x110, Pressed: true, Modifiers: mods})
310		r.Input(&Input{Kind: InputPointerRelease, X: x, Y: 5, Button: 0x110, Modifiers: mods})
311	}
312	for _, lock := range []Modifiers{0, ModCapsLock, ModNumLock, ModCapsLock | ModNumLock} {
313		t.Run(fmt.Sprintf("lock=%#x", uint8(lock)), func(t *testing.T) {
314			t.Run("CtrlA and ShiftLeft", func(t *testing.T) {
315				value := "hello"
316				r, settle := settledRenderer(t, &value, inputView)
317				click(r, 5, lock)
318				settle()
319				key(r, 'a', "a", ModCtrl|lock) // select all
320				settle()
321				key(r, 'X', "X", lock)
322				settle()
323				if value != "X" {
324					t.Fatalf("Ctrl+A then text: value=%q, want X", value)
325				}
326				key(r, 0xff51, "", ModShift|lock) // extend selection left
327				settle()
328				key(r, 'y', "y", lock)
329				settle()
330				if value != "y" {
331					t.Fatalf("Shift+Left then text: value=%q, want y", value)
332				}
333			})
334			t.Run("ShiftClick", func(t *testing.T) {
335				value := "hello"
336				r, settle := settledRenderer(t, &value, inputView)
337				click(r, 1, lock)
338				settle()
339				click(r, 95, ModShift|lock) // select to the end
340				settle()
341				key(r, 'Z', "Z", lock)
342				settle()
343				if value != "Z" {
344					t.Fatalf("Shift+click then text: value=%q, want Z", value)
345				}
346			})
347			t.Run("TabReturn", func(t *testing.T) {
348				m := testModel{}
349				r, settle := settledRenderer(t, &m, testView)
350				key(r, 0xff09, "", lock)
351				key(r, 0xff0d, "", lock)
352				settle()
353				if m.clicks != 1 {
354					t.Fatalf("Tab+Return clicks=%d, want 1", m.clicks)
355				}
356			})
357		})
358	}
359}
360
361type measureModel struct{ title, body string }
362
363func measureView(f *Frame, m *measureModel) {
364	root := f.Root(Inline("padding:4px"))
365	root.Text(m.title)
366	root.Text(m.body)
367}
368
369func TestRendererMeasureNaturalSizeBeforeResize(t *testing.T) {
370	r, _ := newTestRenderer(t) // the mock target has no expectations: any target call fails
371	m := measureModel{title: "Title", body: "short"}
372	w, h, err := r.Measure(&m, measureView, 400)
373	if err != nil {
374		t.Fatal(err)
375	}
376	if w <= 8 || w >= 400 || h <= 8 || w != float64(int(w)) || h != float64(int(h)) {
377		t.Fatalf("size %g x %g", w, h)
378	}
379	single := h
380	m.body = "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau"
381	w, h, err = r.Measure(&m, measureView, 100)
382	if err != nil {
383		t.Fatal(err)
384	}
385	if w != 100 || h <= single+10 {
386		t.Fatalf("wrapped size %g x %g, one-line height %g", w, h, single)
387	}
388}
389
390func TestRendererMeasureValidation(t *testing.T) {
391	r, _ := newTestRenderer(t)
392	m := measureModel{}
393	for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1)} {
394		if _, _, err := r.Measure(&m, measureView, bad); err == nil {
395			t.Errorf("maxWidth %v accepted", bad)
396		}
397	}
398	if _, _, err := r.Measure[measureModel](nil, measureView, 100); err == nil {
399		t.Error("nil model accepted")
400	}
401	if _, _, err := r.Measure(&m, nil, 100); err == nil {
402		t.Error("nil view accepted")
403	}
404	if w, h, err := r.Measure(&m, func(*Frame, *measureModel) {}, 100); err != nil || w != 0 || h != 0 {
405		t.Errorf("view without root: %g x %g %v", w, h, err)
406	}
407	tg := r.target.(*mocktarget)
408	tg.EXPECT().Close().Return(nil).Once()
409	_ = r.Close()
410	if _, _, err := r.Measure(&m, measureView, 100); err == nil {
411		t.Error("closed renderer accepted")
412	}
413}
414
415func TestRendererMeasureChangesNoState(t *testing.T) {
416	r, tg := newTestRenderer(t)
417	type model struct {
418		clicks int
419		text   string
420	}
421	m := model{text: "edit me"}
422	view := func(f *Frame, m *model) {
423		root := f.Root()
424		if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
425			m.clicks++
426		}
427		root.Input("field", &m.text, Key("field"))
428	}
429	var out Output
430	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
431	r.Resize(200, 100, 1)
432	drawOK(tg, nil).Maybe()
433	render := func() {
434		t.Helper()
435		for i := 0; i < 3; i++ {
436			if _, err := r.Render(&out, &m, view); err != nil {
437				t.Fatal(err)
438			}
439		}
440	}
441	render()
442	r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5})
443	r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true}) // Tab: focus the button
444	render()
445	draws := len(tg.Calls)
446	rt := r.rt
447	if rt.state.hover == nil || rt.state.focus == nil {
448		t.Fatalf("setup: hover=%v focus=%v", rt.state.hover, rt.state.focus)
449	}
450	// Input queued but not yet built must survive Measure untouched.
451	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
452	r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
453	before := struct {
454		state                interactionState
455		committed            *element
456		generation           uint64
457		edits, pending, wake int
458		styles               any
459		engine               any
460	}{rt.state, rt.committed, rt.generation, len(rt.edits), len(rt.pending), len(rt.wake), rt.styles, rt.textEngine}
461	if before.pending == 0 || before.wake == 0 {
462		t.Fatalf("setup: pending=%d wake=%d", before.pending, before.wake)
463	}
464
465	if _, _, err := r.Measure(&m, view, 300); err != nil {
466		t.Fatal(err)
467	}
468
469	if rt.state.hover != before.state.hover || rt.state.focus != before.state.focus || rt.state.x != before.state.x || rt.state.y != before.state.y || rt.state.inside != before.state.inside || rt.state.down != before.state.down {
470		t.Fatalf("interaction state changed: %+v -> %+v", before.state, rt.state)
471	}
472	if rt.committed != before.committed || rt.generation != before.generation {
473		t.Fatal("committed tree or generation changed")
474	}
475	if len(rt.edits) != before.edits || rt.styles != before.styles || rt.textEngine != before.engine {
476		t.Fatal("runtime parts changed")
477	}
478	if len(rt.pending) != before.pending || len(rt.wake) != before.wake {
479		t.Fatalf("pending %d -> %d, wake %d -> %d", before.pending, len(rt.pending), before.wake, len(rt.wake))
480	}
481	if m.clicks != 0 || m.text != "edit me" {
482		t.Fatalf("model changed: %+v", m)
483	}
484	if len(tg.Calls) != draws {
485		t.Fatalf("target called by Measure: %d -> %d calls", draws, len(tg.Calls))
486	}
487	render()
488	if m.clicks != 1 {
489		t.Fatalf("the click queued before Measure was lost: clicks=%d", m.clicks)
490	}
491	if rt.state.hover == nil || rt.state.focus == nil {
492		t.Fatalf("hover/focus lost after Render: %+v", rt.state)
493	}
494}
495
496const 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
498func 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
547func 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
592func 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
621func 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}

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