v0.5.1 657f658

renderer_test.go RawHistoryFolder

1//go:build linux
2
3package ui
4
5import (
6	"errors"
7	"testing"
8
9	"github.com/bnema/nefergui/internal/layout"
10	"github.com/bnema/nefergui/internal/presentation/session"
11	"github.com/bnema/nefergui/internal/text"
12	"github.com/stretchr/testify/mock"
13)
14
15type testModel struct {
16	clicks int
17	rects  []Rect
18}
19
20func testView(f *Frame, m *testModel) {
21	root := f.Root()
22	if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
23		m.clicks++
24	}
25	if m.rects != nil {
26		_ = f.SetInputRects(m.rects)
27	}
28}
29
30func newTestRenderer(t *testing.T) (*Renderer, *mocktarget) {
31	t.Helper()
32	tg := newMocktarget(t)
33	r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
34	if err != nil {
35		t.Fatal(err)
36	}
37	return r, tg
38}
39
40func drawOK(tg *mocktarget, fill func(*session.Output)) *mocktarget_Draw_Call {
41	return tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).
42		RunAndReturn(func(_ []layout.Command, _ float64, out *session.Output) (bool, error) {
43			*out = session.Output{Buffer: 1, Width: 100, Height: 50}
44			if fill != nil {
45				fill(out)
46			}
47			return true, nil
48		})
49}
50
51func TestKeysymNames(t *testing.T) {
52	for sym, want := range map[uint32]string{
53		0x20: "space", 0xff09: "tab", 0xfe20: "iso_left_tab", 0xff51: "left", 0xff53: "right",
54		0xff52: "up", 0xff54: "down", 0xff50: "home", 0xff57: "end", 0xff55: "page_up",
55		0xff56: "page_down", 0xff08: "backspace", 0xffff: "delete", 'a': "a", 'A': "a",
56		'c': "c", 'x': "x", 'v': "v", 0xff0d: "return", 0xff8d: "kp_enter", 0xff1b: "escape",
57		'z': "", 0xffe1: "",
58	} {
59		if got := keysymName(sym); got != want {
60			t.Errorf("keysym %#x: got %q want %q", sym, got, want)
61		}
62	}
63}
64
65func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
66	r, tg := newTestRenderer(t)
67	var m testModel
68	var out Output
69	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
70		t.Fatalf("before Resize: ok=%v err=%v", ok, err)
71	}
72	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
73	r.Resize(100, 50, 2)
74	drawOK(tg, nil).Once()
75	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
76		t.Fatalf("first render: ok=%v err=%v", ok, err)
77	}
78	if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
79		t.Fatalf("output: %+v", out)
80	}
81	// Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
82	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
83		t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
84	}
85}
86
87func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
88	r, tg := newTestRenderer(t)
89	var m testModel
90	var out Output
91	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
92	r.Resize(100, 50, 1)
93	tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
94	tg.EXPECT().Waiting().Return(true).Once()
95	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
96		t.Fatalf("no buffer: ok=%v err=%v", ok, err)
97	}
98	if !r.Pending() {
99		t.Fatal("a built frame blocked on the GPU must report Pending")
100	}
101	drawOK(tg, nil).Once()
102	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
103		t.Fatalf("retry: ok=%v err=%v", ok, err)
104	}
105}
106
107func TestRendererDrawError(t *testing.T) {
108	r, tg := newTestRenderer(t)
109	var m testModel
110	var out Output
111	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
112	r.Resize(100, 50, 1)
113	boom := errors.New("boom")
114	tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, boom).Once()
115	if ok, err := r.Render(&out, &m, testView); ok || !errors.Is(err, boom) {
116		t.Fatalf("ok=%v err=%v", ok, err)
117	}
118}
119
120func TestRendererResizeReachesTargetInPhysicalPixels(t *testing.T) {
121	r, tg := newTestRenderer(t)
122	tg.EXPECT().Resize(int32(150), int32(75)).Return().Once()
123	r.Resize(100, 50, 1.5)
124	r.Resize(100, 50, 1.5) // unchanged: no second call
125	r.Resize(0, 50, 1)     // invalid: ignored
126	r.Resize(10, 10, 0)    // invalid: ignored
127}
128
129func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
130	r, tg := newTestRenderer(t)
131	var m testModel
132	var out Output
133	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
134	r.Resize(200, 100, 1)
135	drawOK(tg, nil).Maybe()
136	for i := 0; i < 3; i++ {
137		if _, err := r.Render(&out, &m, testView); err != nil {
138			t.Fatal(err)
139		}
140	}
141	if r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) != true {
142		t.Fatal("hover over a button needs a redraw")
143	}
144	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
145	if !r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) {
146		t.Fatal("release did not request redraw")
147	}
148	for i := 0; i < 3; i++ {
149		if _, err := r.Render(&out, &m, testView); err != nil {
150			t.Fatal(err)
151		}
152	}
153	if m.clicks != 1 {
154		t.Fatalf("clicks=%d", m.clicks)
155	}
156	// Keyboard: Tab focuses, Return activates, text comes from Text.
157	r.Input(&Input{Kind: InputPointerLeave})
158	r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true})
159	r.Input(&Input{Kind: InputKey, Keysym: 0xff0d, Pressed: true})
160	for i := 0; i < 3; i++ {
161		if _, err := r.Render(&out, &m, testView); err != nil {
162			t.Fatal(err)
163		}
164	}
165	if m.clicks != 2 {
166		t.Fatalf("Return did not activate the focused button: clicks=%d", m.clicks)
167	}
168	if r.Input(&Input{Kind: 99}) || r.Input(nil) {
169		t.Fatal("unknown input requested a redraw")
170	}
171	if r.Input(&Input{Kind: InputPointerAxis}) {
172		t.Fatal("zero scroll requested a redraw")
173	}
174}
175
176func TestRendererTextInputUsesTextBytes(t *testing.T) {
177	r, tg := newTestRenderer(t)
178	value := ""
179	var out Output
180	view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
181	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
182	r.Resize(200, 100, 1)
183	drawOK(tg, nil).Maybe()
184	settle := func() {
185		for i := 0; i < 3; i++ {
186			if _, err := r.Render(&out, &value, view); err != nil {
187				t.Fatal(err)
188			}
189		}
190	}
191	settle()
192	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
193	r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
194	settle()
195	text := []byte("é")
196	r.Input(&Input{Kind: InputKey, Keysym: 0xe9, Pressed: true, Text: text})
197	text[0] = 0 // the caller may reuse the buffer after the call
198	settle()
199	if value != "é" {
200		t.Fatalf("value=%q", value)
201	}
202}
203
204func TestRendererOutputCarriesCursorAndInputRects(t *testing.T) {
205	r, tg := newTestRenderer(t)
206	m := testModel{rects: []Rect{{1, 2, 30, 20}}}
207	var out Output
208	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
209	r.Resize(200, 100, 1)
210	drawOK(tg, nil).Maybe()
211	r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) // over the button: pointer cursor
212	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
213		t.Fatalf("ok=%v err=%v", ok, err)
214	}
215	if out.Cursor != CursorPointer || !out.InputRectsChanged || len(out.InputRects) != 1 || out.InputRects[0] != (Rect{1, 2, 30, 20}) {
216		t.Fatalf("output: cursor=%v changed=%v rects=%v", out.Cursor, out.InputRectsChanged, out.InputRects)
217	}
218	// An unchanged region is not reported again.
219	r.Invalidate()
220	ok, err := r.Render(&out, &m, testView)
221	if err != nil {
222		t.Fatal(err)
223	}
224	if ok && out.InputRectsChanged && len(out.InputRects) != 0 {
225		t.Fatalf("region reported twice: %+v", out)
226	}
227	// nil restores the whole surface: changed with no rects.
228	m.rects = nil
229	var nilView = func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }
230	r.Invalidate()
231	if ok, err = r.Render(&out, &m, nilView); err != nil || !ok || !out.InputRectsChanged || out.InputRects != nil {
232		t.Fatalf("nil region: ok=%v err=%v %+v", ok, err, out)
233	}
234}
235
236func TestRendererForwardsReleasedAndClose(t *testing.T) {
237	r, tg := newTestRenderer(t)
238	tg.EXPECT().Released(uint64(3)).Return(nil).Once()
239	if err := r.Released(3); err != nil {
240		t.Fatal(err)
241	}
242	tg.EXPECT().Close().Return(nil).Once()
243	if err := r.Close(); err != nil {
244		t.Fatal(err)
245	}
246	// After Close nothing reaches the target (the mock has no expectation).
247	r.Resize(10, 10, 1)
248	if r.Input(&Input{Kind: InputPointerMotion}) {
249		t.Fatal("Input after Close requested a redraw")
250	}
251	if err := r.Released(3); err == nil {
252		t.Fatal("Released after Close succeeded")
253	}
254	if err := r.Close(); err != nil { // second Close is a no-op
255		t.Fatal(err)
256	}
257	var out Output
258	var m testModel
259	if _, err := r.Render(&out, &m, testView); err == nil {
260		t.Fatal("Render after Close succeeded")
261	}
262}
263
264func TestTextIntAndMasked(t *testing.T) {
265	r := newRuntime()
266	r.Build(func(f *Frame) {
267		root := f.Root()
268		root.TextInt("Count: ", -42)
269		root.Masked(3)
270		root.Masked(0)
271		root.Masked(300)
272	})
273	c := r.committed.children
274	if len(c) != 4 || c[0].text != "Count: -42" || c[1].text != "•••" || c[2].text != "" || len([]rune(c[3].text)) != 300 {
275		t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
276	}
277}

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