v0.6.0 43c5a7c

renderer_test.go RawHistoryFolder

1//go:build linux
2
3package ui
4
5import (
6	"errors"
7	"fmt"
8	"testing"
9
10	"github.com/bnema/nefergui/internal/layout"
11	"github.com/bnema/nefergui/internal/presentation/session"
12	"github.com/bnema/nefergui/internal/text"
13	"github.com/stretchr/testify/mock"
14)
15
16type testModel struct {
17	clicks int
18	rects  []Rect
19}
20
21func testView(f *Frame, m *testModel) {
22	root := f.Root()
23	if root.Button("Go", Key("go"), Inline("width:60px;height:20px")).Activated() {
24		m.clicks++
25	}
26	if m.rects != nil {
27		_ = f.SetInputRects(m.rects)
28	}
29}
30
31func newTestRenderer(t *testing.T) (*Renderer, *mocktarget) {
32	t.Helper()
33	tg := newMocktarget(t)
34	r, err := newRenderer(RendererConfig{}, tg, text.DirectorySource("../../testdata/fonts"))
35	if err != nil {
36		t.Fatal(err)
37	}
38	return r, tg
39}
40
41func drawOK(tg *mocktarget, fill func(*session.Output)) *mocktarget_Draw_Call {
42	return tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).
43		RunAndReturn(func(_ []layout.Command, _ float64, out *session.Output) (bool, error) {
44			*out = session.Output{Buffer: 1, Width: 100, Height: 50}
45			if fill != nil {
46				fill(out)
47			}
48			return true, nil
49		})
50}
51
52func TestKeysymNames(t *testing.T) {
53	for sym, want := range map[uint32]string{
54		0x20: "space", 0xff09: "tab", 0xfe20: "iso_left_tab", 0xff51: "left", 0xff53: "right",
55		0xff52: "up", 0xff54: "down", 0xff50: "home", 0xff57: "end", 0xff55: "page_up",
56		0xff56: "page_down", 0xff08: "backspace", 0xffff: "delete", 'a': "a", 'A': "a",
57		'c': "c", 'x': "x", 'v': "v", 0xff0d: "return", 0xff8d: "kp_enter", 0xff1b: "escape",
58		'z': "", 0xffe1: "",
59	} {
60		if got := keysymName(sym); got != want {
61			t.Errorf("keysym %#x: got %q want %q", sym, got, want)
62		}
63	}
64}
65
66func TestRendererRenderBeforeResizeAndWhenUnchanged(t *testing.T) {
67	r, tg := newTestRenderer(t)
68	var m testModel
69	var out Output
70	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
71		t.Fatalf("before Resize: ok=%v err=%v", ok, err)
72	}
73	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
74	r.Resize(100, 50, 2)
75	drawOK(tg, nil).Once()
76	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
77		t.Fatalf("first render: ok=%v err=%v", ok, err)
78	}
79	if out.Buffer != 1 || out.Width != 100 || len(out.Damage) != 1 || out.Damage[0] != (Rect{Width: 100, Height: 50}) {
80		t.Fatalf("output: %+v", out)
81	}
82	// Nothing changed: no rebuild result, so no Draw (the mock has no further expectation).
83	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
84		t.Fatalf("unchanged view: ok=%v err=%v", ok, err)
85	}
86}
87
88func TestRendererKeepsFrameWhenNoBufferFree(t *testing.T) {
89	r, tg := newTestRenderer(t)
90	var m testModel
91	var out Output
92	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
93	r.Resize(100, 50, 1)
94	tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, nil).Once()
95	tg.EXPECT().Waiting().Return(true).Once()
96	if ok, err := r.Render(&out, &m, testView); ok || err != nil {
97		t.Fatalf("no buffer: ok=%v err=%v", ok, err)
98	}
99	if !r.Pending() {
100		t.Fatal("a built frame blocked on the GPU must report Pending")
101	}
102	drawOK(tg, nil).Once()
103	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
104		t.Fatalf("retry: ok=%v err=%v", ok, err)
105	}
106}
107
108func TestRendererDrawError(t *testing.T) {
109	r, tg := newTestRenderer(t)
110	var m testModel
111	var out Output
112	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
113	r.Resize(100, 50, 1)
114	boom := errors.New("boom")
115	tg.EXPECT().Draw(mock.Anything, mock.Anything, mock.Anything).Return(false, boom).Once()
116	if ok, err := r.Render(&out, &m, testView); ok || !errors.Is(err, boom) {
117		t.Fatalf("ok=%v err=%v", ok, err)
118	}
119}
120
121func TestRendererResizeReachesTargetInPhysicalPixels(t *testing.T) {
122	r, tg := newTestRenderer(t)
123	tg.EXPECT().Resize(int32(150), int32(75)).Return().Once()
124	r.Resize(100, 50, 1.5)
125	r.Resize(100, 50, 1.5) // unchanged: no second call
126	r.Resize(0, 50, 1)     // invalid: ignored
127	r.Resize(10, 10, 0)    // invalid: ignored
128}
129
130func TestRendererInputActivatesButtonAndRequestsRedraw(t *testing.T) {
131	r, tg := newTestRenderer(t)
132	var m testModel
133	var out Output
134	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
135	r.Resize(200, 100, 1)
136	drawOK(tg, nil).Maybe()
137	for i := 0; i < 3; i++ {
138		if _, err := r.Render(&out, &m, testView); err != nil {
139			t.Fatal(err)
140		}
141	}
142	if r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) != true {
143		t.Fatal("hover over a button needs a redraw")
144	}
145	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
146	if !r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110}) {
147		t.Fatal("release did not request redraw")
148	}
149	for i := 0; i < 3; i++ {
150		if _, err := r.Render(&out, &m, testView); err != nil {
151			t.Fatal(err)
152		}
153	}
154	if m.clicks != 1 {
155		t.Fatalf("clicks=%d", m.clicks)
156	}
157	// Keyboard: Tab focuses, Return activates, text comes from Text.
158	r.Input(&Input{Kind: InputPointerLeave})
159	r.Input(&Input{Kind: InputKey, Keysym: 0xff09, Pressed: true})
160	r.Input(&Input{Kind: InputKey, Keysym: 0xff0d, Pressed: true})
161	for i := 0; i < 3; i++ {
162		if _, err := r.Render(&out, &m, testView); err != nil {
163			t.Fatal(err)
164		}
165	}
166	if m.clicks != 2 {
167		t.Fatalf("Return did not activate the focused button: clicks=%d", m.clicks)
168	}
169	if r.Input(&Input{Kind: 99}) || r.Input(nil) {
170		t.Fatal("unknown input requested a redraw")
171	}
172	if r.Input(&Input{Kind: InputPointerAxis}) {
173		t.Fatal("zero scroll requested a redraw")
174	}
175}
176
177func TestRendererTextInputUsesTextBytes(t *testing.T) {
178	value := ""
179	view := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
180	r, settle := settledRenderer(t, &value, view)
181	r.Input(&Input{Kind: InputPointerPress, X: 5, Y: 5, Button: 0x110, Pressed: true})
182	r.Input(&Input{Kind: InputPointerRelease, X: 5, Y: 5, Button: 0x110})
183	settle()
184	text := []byte("é")
185	r.Input(&Input{Kind: InputKey, Keysym: 0xe9, Pressed: true, Text: text})
186	text[0] = 0 // the caller may reuse the buffer after the call
187	settle()
188	if value != "é" {
189		t.Fatalf("value=%q", value)
190	}
191}
192
193func TestRendererOutputCarriesCursorAndInputRects(t *testing.T) {
194	r, tg := newTestRenderer(t)
195	m := testModel{rects: []Rect{{1, 2, 30, 20}}}
196	var out Output
197	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
198	r.Resize(200, 100, 1)
199	drawOK(tg, nil).Maybe()
200	r.Input(&Input{Kind: InputPointerMotion, X: 5, Y: 5}) // over the button: pointer cursor
201	if ok, err := r.Render(&out, &m, testView); !ok || err != nil {
202		t.Fatalf("ok=%v err=%v", ok, err)
203	}
204	if out.Cursor != CursorPointer || !out.InputRectsChanged || len(out.InputRects) != 1 || out.InputRects[0] != (Rect{1, 2, 30, 20}) {
205		t.Fatalf("output: cursor=%v changed=%v rects=%v", out.Cursor, out.InputRectsChanged, out.InputRects)
206	}
207	// An unchanged region is not reported again.
208	r.Invalidate()
209	ok, err := r.Render(&out, &m, testView)
210	if err != nil {
211		t.Fatal(err)
212	}
213	if ok && out.InputRectsChanged && len(out.InputRects) != 0 {
214		t.Fatalf("region reported twice: %+v", out)
215	}
216	// nil restores the whole surface: changed with no rects.
217	m.rects = nil
218	var nilView = func(f *Frame, _ *testModel) { f.Root(); _ = f.SetInputRects(nil) }
219	r.Invalidate()
220	if ok, err = r.Render(&out, &m, nilView); err != nil || !ok || !out.InputRectsChanged || out.InputRects != nil {
221		t.Fatalf("nil region: ok=%v err=%v %+v", ok, err, out)
222	}
223}
224
225func TestRendererForwardsReleasedAndClose(t *testing.T) {
226	r, tg := newTestRenderer(t)
227	tg.EXPECT().Released(uint64(3)).Return(nil).Once()
228	if err := r.Released(3); err != nil {
229		t.Fatal(err)
230	}
231	tg.EXPECT().Close().Return(nil).Once()
232	if err := r.Close(); err != nil {
233		t.Fatal(err)
234	}
235	// After Close nothing reaches the target (the mock has no expectation).
236	r.Resize(10, 10, 1)
237	if r.Input(&Input{Kind: InputPointerMotion}) {
238		t.Fatal("Input after Close requested a redraw")
239	}
240	if err := r.Released(3); err == nil {
241		t.Fatal("Released after Close succeeded")
242	}
243	if err := r.Close(); err != nil { // second Close is a no-op
244		t.Fatal(err)
245	}
246	var out Output
247	var m testModel
248	if _, err := r.Render(&out, &m, testView); err == nil {
249		t.Fatal("Render after Close succeeded")
250	}
251}
252
253func TestTextIntAndMasked(t *testing.T) {
254	r := newRuntime()
255	r.Build(func(f *Frame) {
256		root := f.Root()
257		root.TextInt("Count: ", -42)
258		root.Masked(3)
259		root.Masked(0)
260		root.Masked(300)
261	})
262	c := r.committed.children
263	if len(c) != 4 || c[0].text != "Count: -42" || c[1].text != "•••" || c[2].text != "" || len([]rune(c[3].text)) != 300 {
264		t.Fatalf("texts: %q %q %q", c[0].text, c[1].text, c[2].text)
265	}
266}
267
268// settledRenderer returns a 200x100 renderer for view and a settle function
269// that renders until queued input has been applied.
270func settledRenderer[T any](t *testing.T, model *T, view func(*Frame, *T)) (*Renderer, func()) {
271	t.Helper()
272	r, tg := newTestRenderer(t)
273	tg.EXPECT().Resize(mock.Anything, mock.Anything).Return().Once()
274	r.Resize(200, 100, 1)
275	drawOK(tg, nil).Maybe()
276	var out Output
277	settle := func() {
278		for i := 0; i < 3; i++ {
279			if _, err := r.Render(&out, model, view); err != nil {
280				t.Fatal(err)
281			}
282		}
283	}
284	settle()
285	return r, settle
286}
287
288func TestModifierBits(t *testing.T) {
289	// The values match neferclient's, so applications copy the bits as is.
290	if ModShift != 1 || ModCtrl != 2 || ModAlt != 4 || ModSuper != 8 || ModCapsLock != 16 || ModNumLock != 32 {
291		t.Fatal("modifier bit values changed")
292	}
293}
294
295// Caps Lock and Num Lock must not change shortcuts, selection or activation.
296func TestRendererLockModifiersDoNotChangeInput(t *testing.T) {
297	inputView := func(f *Frame, v *string) { f.Root().Input("name", v, Key("name"), Inline("width:100px;height:20px")) }
298	key := func(r *Renderer, keysym uint32, text string, mods Modifiers) {
299		in := &Input{Kind: InputKey, Keysym: keysym, Pressed: true, Modifiers: mods}
300		if text != "" {
301			in.Text = []byte(text)
302		}
303		r.Input(in)
304	}
305	click := func(r *Renderer, x float64, mods Modifiers) {
306		r.Input(&Input{Kind: InputPointerPress, X: x, Y: 5, Button: 0x110, Pressed: true, Modifiers: mods})
307		r.Input(&Input{Kind: InputPointerRelease, X: x, Y: 5, Button: 0x110, Modifiers: mods})
308	}
309	for _, lock := range []Modifiers{0, ModCapsLock, ModNumLock, ModCapsLock | ModNumLock} {
310		t.Run(fmt.Sprintf("lock=%#x", uint8(lock)), func(t *testing.T) {
311			t.Run("CtrlA and ShiftLeft", func(t *testing.T) {
312				value := "hello"
313				r, settle := settledRenderer(t, &value, inputView)
314				click(r, 5, lock)
315				settle()
316				key(r, 'a', "a", ModCtrl|lock) // select all
317				settle()
318				key(r, 'X', "X", lock)
319				settle()
320				if value != "X" {
321					t.Fatalf("Ctrl+A then text: value=%q, want X", value)
322				}
323				key(r, 0xff51, "", ModShift|lock) // extend selection left
324				settle()
325				key(r, 'y', "y", lock)
326				settle()
327				if value != "y" {
328					t.Fatalf("Shift+Left then text: value=%q, want y", value)
329				}
330			})
331			t.Run("ShiftClick", func(t *testing.T) {
332				value := "hello"
333				r, settle := settledRenderer(t, &value, inputView)
334				click(r, 1, lock)
335				settle()
336				click(r, 95, ModShift|lock) // select to the end
337				settle()
338				key(r, 'Z', "Z", lock)
339				settle()
340				if value != "Z" {
341					t.Fatalf("Shift+click then text: value=%q, want Z", value)
342				}
343			})
344			t.Run("TabReturn", func(t *testing.T) {
345				m := testModel{}
346				r, settle := settledRenderer(t, &m, testView)
347				key(r, 0xff09, "", lock)
348				key(r, 0xff0d, "", lock)
349				settle()
350				if m.clicks != 1 {
351					t.Fatalf("Tab+Return clicks=%d, want 1", m.clicks)
352				}
353			})
354		})
355	}
356}

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