v0.4.0 2de4e1d

hooks_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"math"
7	"net"
8	"path/filepath"
9	"strconv"
10	"testing"
11	"time"
12
13	"github.com/bnema/wlturbo"
14	"github.com/bnema/wlturbo/wl"
15
16	"github.com/bnema/nefergui/internal/css"
17	"github.com/bnema/nefergui/internal/keyboard"
18	"github.com/bnema/nefergui/internal/layout"
19	"github.com/bnema/nefergui/internal/platform/wayland"
20	"github.com/bnema/nefergui/internal/text"
21)
22
23func TestLayerConfigSurfaceOptions(t *testing.T) {
24	c := LayerConfig{Output: "DP-1", Namespace: "ns", Level: LayerOverlay, Anchors: AnchorTop | AnchorLeft | AnchorRight | AnchorBottom,
25		Keyboard: KeyboardExclusive, ExclusiveZone: -1, Margin: [4]int32{1, 2, 3, 4}, InputRects: []Rect{{1, 2, 3, 4}}}
26	got, err := c.surfaceOptions()
27	if err != nil {
28		t.Fatal(err)
29	}
30	l := got.Layer
31	if l == nil || l.Output != "DP-1" || l.Namespace != "ns" || l.Layer != wayland.LayerOverlay || l.Anchor != 15 ||
32		l.Keyboard != wayland.KeyboardExclusive || l.ExclusiveZone != -1 || l.Margin != [4]int32{1, 2, 3, 4} {
33		t.Fatalf("layer options: %+v", l)
34	}
35	if len(got.InputRects) != 1 || got.InputRects[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
36		t.Fatalf("input rects: %+v", got.InputRects)
37	}
38	for level, want := range map[LayerLevel]uint32{LayerBackground: 0, LayerBottom: 1, LayerTop: 2, LayerOverlay: 3} {
39		o, err := LayerConfig{Level: level}.surfaceOptions()
40		if err != nil || o.Layer.Layer != want {
41			t.Fatalf("level %d: %+v %v", level, o.Layer, err)
42		}
43	}
44	// nil InputRects keeps the full surface; empty non-nil means click-through.
45	if o, _ := (LayerConfig{Level: LayerTop}).surfaceOptions(); o.InputRects != nil {
46		t.Fatal("nil rects became non-nil")
47	}
48	if o, _ := (LayerConfig{Level: LayerTop, InputRects: []Rect{}}).surfaceOptions(); o.InputRects == nil {
49		t.Fatal("empty rects became nil")
50	}
51}
52
53func TestLayerConfigRejectsInvalid(t *testing.T) {
54	for name, c := range map[string]LayerConfig{
55		"zero level":   {},
56		"bad level":    {Level: 9},
57		"bad anchor":   {Level: LayerTop, Anchors: 1 << 5},
58		"bad keyboard": {Level: LayerTop, Keyboard: 7},
59		"empty rect":   {Level: LayerTop, InputRects: []Rect{{Width: 0, Height: 5}}},
60		"nul output":   {Level: LayerTop, Output: "a\x00"},
61		"all+output":   {Level: LayerTop, AllOutputs: true, Output: "DP-1"},
62		"all+keyboard": {Level: LayerTop, AllOutputs: true, Keyboard: KeyboardOnDemand},
63	} {
64		if _, err := c.surfaceOptions(); err == nil {
65			t.Errorf("%s: expected error", name)
66		}
67	}
68}
69
70func TestLayerCopiesInputRects(t *testing.T) {
71	rects := []Rect{{0, 0, 1, 1}}
72	var cfg windowConfig
73	Layer(LayerConfig{Level: LayerTop, InputRects: rects}).window(&cfg)
74	rects[0].Width = 99
75	if cfg.layer.InputRects[0].Width != 1 {
76		t.Fatal("option aliases caller slice")
77	}
78}
79
80func TestInputEventMapping(t *testing.T) {
81	ev, ok := toInputEvent(wayland.Input{Kind: "press", X: 1.5, Y: 2.5, Button: 0x110})
82	if !ok || ev.Kind != InputPointerPress || !ev.Pressed || ev.X != 1.5 || ev.Y != 2.5 || ev.Button != 0x110 {
83		t.Fatalf("press: %+v", ev)
84	}
85	ev, _ = toInputEvent(wayland.Input{Kind: "release", Button: 0x110})
86	if ev.Kind != InputPointerRelease || ev.Pressed {
87		t.Fatalf("release: %+v", ev)
88	}
89	ev, ok = toInputEvent(wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Text: "x", Pressed: true, Repeat: true, Shift: true, Ctrl: true}})
90	if !ok || ev.Kind != InputKey || ev.KeyName != "Escape" || ev.Text != "x" || !ev.Pressed || !ev.Repeat || ev.Modifiers != ModShift|ModCtrl {
91		t.Fatalf("key: %+v", ev)
92	}
93	ev, _ = toInputEvent(wayland.Input{Kind: "axis", DX: 1, DY: -2})
94	if ev.Kind != InputPointerAxis || ev.DX != 1 || ev.DY != -2 {
95		t.Fatalf("axis: %+v", ev)
96	}
97	for kind, want := range map[string]InputKind{"motion": InputPointerMotion, "leave": InputPointerLeave, "focus-in": InputFocusIn, "focus-out": InputFocusOut, wayland.InputReset: InputReset} {
98		if ev, ok = toInputEvent(wayland.Input{Kind: kind}); !ok || ev.Kind != want {
99			t.Fatalf("%s: %+v", kind, ev)
100		}
101	}
102	if _, ok = toInputEvent(wayland.Input{Kind: "unknown"}); ok {
103		t.Fatal("unknown kind delivered")
104	}
105}
106
107func TestInputHookDelivery(t *testing.T) {
108	var got []InputEvent
109	h := &inputHooks{onInput: func(ev InputEvent) bool { got = append(got, ev); return ev.Kind == InputPointerPress }}
110	if h.input(wayland.Input{Kind: "motion", X: 3, Y: 4}) {
111		t.Fatal("motion reported a change")
112	}
113	if !h.input(wayland.Input{Kind: "press", Button: 1}) {
114		t.Fatal("press change not reported")
115	}
116	if len(got) != 2 || got[0].Kind != InputPointerMotion {
117		t.Fatalf("got %+v", got)
118	}
119	// Absent callbacks and steady-state delivery do not allocate.
120	idle := &inputHooks{}
121	in := wayland.Input{Kind: "key", Key: keyboard.Key{Name: "Escape", Pressed: true}}
122	if n := testing.AllocsPerRun(100, func() { idle.input(in) }); n != 0 {
123		t.Fatalf("idle input allocs %v", n)
124	}
125	count := 0
126	live := &inputHooks{onInput: func(InputEvent) bool { count++; return false }}
127	if n := testing.AllocsPerRun(100, func() { live.input(in) }); n != 0 {
128		t.Fatalf("hooked input allocs %v", n)
129	}
130	if n := testing.AllocsPerRun(100, func() { idle.resize(10, 10, 1) }); n != 0 {
131		t.Fatalf("idle resize allocs %v", n)
132	}
133}
134
135func TestResizeReportedOnlyOnChange(t *testing.T) {
136	type size struct {
137		w, h int
138		s    float64
139	}
140	var got []size
141	h := &inputHooks{onResize: func(w, hh int, s float64) { got = append(got, size{w, hh, s}) }}
142	var changed []bool
143	for _, s := range []size{{100, 50, 1}, {100, 50, 1}, {100, 60, 1}, {100, 60, 1}, {100, 60, 2}, {100, 60, 2}} {
144		changed = append(changed, h.resize(s.w, s.h, s.s))
145	}
146	want := []size{{100, 50, 1}, {100, 60, 1}, {100, 60, 2}}
147	if len(got) != len(want) {
148		t.Fatalf("resize reports %v", got)
149	}
150	for i := range want {
151		if got[i] != want[i] {
152			t.Fatalf("report %d: %v", i, got[i])
153		}
154	}
155	if !changed[0] || changed[1] || !changed[2] || changed[3] || !changed[4] || changed[5] {
156		t.Fatalf("changed flags %v", changed)
157	}
158}
159
160func TestExternalWakeForcesBuild(t *testing.T) {
161	r := newRuntime()
162	builds := 0
163	view := func(f *Frame) { builds++; f.Root() }
164	r.Build(view)
165	if r.Build(view) {
166		t.Fatal("idle runtime rebuilt")
167	}
168	ch := make(chan struct{})
169	ctx, cancel := context.WithCancel(context.Background())
170	defer cancel()
171	done := make(chan struct{})
172	go func() { r.forwardWake(ctx, ch); close(done) }()
173	ch <- struct{}{}
174	waitCtx, waitCancel := context.WithTimeout(ctx, time.Second)
175	defer waitCancel()
176	if err := r.Wait(waitCtx); err != nil {
177		t.Fatal("wake not forwarded:", err)
178	}
179	if !r.Build(view) || builds != 2 {
180		t.Fatalf("wake did not rebuild: builds=%d", builds)
181	}
182	close(ch)
183	select {
184	case <-done:
185	case <-time.After(time.Second):
186		t.Fatal("forwarder did not stop on close")
187	}
188}
189
190// AllOutputs redraws the other surfaces only after a build that handled events.
191func TestBuildReportsConsumedInput(t *testing.T) {
192	r := newRuntime()
193	view := func(f *Frame) { f.Root().Button("Go", Key("go")) }
194	r.Build(view)
195	if r.consumedInput() {
196		t.Fatal("first build consumed nothing")
197	}
198	r.Queue(inputEvent{Target: r.Target(0), Kind: "activate"})
199	if !r.Build(view) || !r.consumedInput() {
200		t.Fatal("event build not reported")
201	}
202	if !r.Build(view) || r.consumedInput() {
203		t.Fatal("follow-up build must not report events")
204	}
205}
206
207func TestNodeRectAndFrameSize(t *testing.T) {
208	r := newRuntime()
209	r.width, r.height = 400, 300
210	var w, h float64
211	r.Build(func(f *Frame) {
212		w, h = f.Size()
213		stack := f.Root().Stack()
214		stack.Box(Key("a")).Rect(10, 20, 30, 40)
215		stack.Box(Key("b")).Rect(50.5, 60.25, 70, 80)
216	})
217	if w != 400 || h != 300 {
218		t.Fatalf("size %v x %v", w, h)
219	}
220	kids := r.output.Tree.Children[0].Children
221	if len(kids) != 2 {
222		t.Fatalf("children %d", len(kids))
223	}
224	if kids[0].Border != (layout.Rect{X: 10, Y: 20, W: 30, H: 40}) || kids[1].Border != (layout.Rect{X: 50.5, Y: 60.25, W: 70, H: 80}) {
225		t.Fatalf("rects %+v %+v", kids[0].Border, kids[1].Border)
226	}
227	// Hit testing follows the rect geometry.
228	if id := layout.Hit(r.output.Tree, 12, 22); id != kids[0].ID {
229		t.Fatalf("hit %q want %q", id, kids[0].ID)
230	}
231}
232
233// Raw input observation must not replace normal control routing.
234func TestRawHookDoesNotBlockControls(t *testing.T) {
235	r := newRuntime()
236	catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
237	if err != nil {
238		t.Fatal(err)
239	}
240	r.setTextEngine(text.NewEngine(catalog))
241	r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px} button {height:20px}`))
242	activated := 0
243	view := shell(r, &activated)
244	r.Build(view)
245	button := r.output.Tree.Children[1]
246	seen := 0
247	h := &inputHooks{onInput: func(ev InputEvent) bool {
248		if ev.Kind == InputPointerPress || ev.Kind == InputPointerRelease {
249			seen++
250		}
251		return false
252	}}
253	for _, in := range []wayland.Input{
254		{Kind: "motion", X: 1, Y: button.Border.Y + 1},
255		{Kind: "press", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
256		{Kind: "release", X: 1, Y: button.Border.Y + 1, Button: pointerPrimary},
257	} {
258		h.input(in)
259		r.routeInput(in)
260	}
261	r.Build(view)
262	if seen != 2 || activated != 1 {
263		t.Fatalf("seen %d activated %d", seen, activated)
264	}
265}
266
267func TestSurfaceHookOptionIsStored(t *testing.T) {
268	var cfg windowConfig
269	OnSurface(nil).window(&cfg)
270	if cfg.onSurface != nil {
271		t.Fatal("nil hook stored")
272	}
273	called := false
274	OnSurface(func(context.Context, WaylandSurface) error { called = true; return nil }).window(&cfg)
275	if cfg.onSurface == nil || cfg.onSurface(context.Background(), WaylandSurface{}) != nil || !called {
276		t.Fatal("hook not stored")
277	}
278}
279
280// testDisplay is a real wlturbo display over a unix socket with no compositor.
281func testDisplay(t *testing.T) *wl.Display {
282	t.Helper()
283	l, err := net.Listen("unix", filepath.Join(t.TempDir(), "s"))
284	if err != nil {
285		t.Fatal(err)
286	}
287	defer l.Close()
288	accepted := make(chan net.Conn, 1)
289	go func() {
290		c, _ := l.Accept()
291		accepted <- c
292	}()
293	c, err := net.Dial("unix", l.Addr().String())
294	if err != nil {
295		t.Fatal(err)
296	}
297	server := <-accepted
298	t.Cleanup(func() { server.Close() })
299	d, err := wlturbo.ConnectFromConn(c)
300	if err != nil {
301		t.Fatal(err)
302	}
303	t.Cleanup(func() { d.Close() })
304	return d
305}
306
307func TestStartupHookErrorsAndCancellation(t *testing.T) {
308	boom := errors.New("boom")
309	// A plain error passes through and does not need a display.
310	if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return boom }, WaylandSurface{}); !errors.Is(err, boom) {
311		t.Fatalf("error: %v", err)
312	}
313	if err := startupHook(context.Background(), func(context.Context, WaylandSurface) error { return nil }, WaylandSurface{}); err != nil {
314		t.Fatalf("nil: %v", err)
315	}
316	// A hook that observes cancellation reports ctx.Err joined with its own error.
317	display := testDisplay(t)
318	ctx, cancel := context.WithCancel(context.Background())
319	err := startupHook(ctx, func(ctx context.Context, _ WaylandSurface) error {
320		cancel()
321		<-ctx.Done()
322		return boom
323	}, WaylandSurface{Display: display})
324	if !errors.Is(err, context.Canceled) || !errors.Is(err, boom) || !display.Closed() {
325		t.Fatalf("cancel: %v", err)
326	}
327	// Cancelling without a hook error still reports the cancellation.
328	ctx, cancel = context.WithCancel(context.Background())
329	err = startupHook(ctx, func(context.Context, WaylandSurface) error { cancel(); return nil }, WaylandSurface{Display: testDisplay(t)})
330	if !errors.Is(err, context.Canceled) {
331		t.Fatalf("cancel without error: %v", err)
332	}
333}
334
335func TestFrameSetInputRects(t *testing.T) {
336	r := newRuntime()
337	var setErr error
338	rects := []Rect{{1, 2, 3, 4}, {5, 6, 7, 8}}
339	r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects(rects) })
340	if setErr != nil {
341		t.Fatal(setErr)
342	}
343	rects[0].Width = 99 // caller reuse must not affect the staged copy
344	var got [][]wayland.Rect
345	set := func(rs []wayland.Rect) error { got = append(got, append([]wayland.Rect{}, rs...)); return nil }
346	if err := r.applyInputRects(set); err != nil {
347		t.Fatal(err)
348	}
349	if len(got) != 1 || len(got[0]) != 2 || got[0][0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
350		t.Fatalf("applied %+v", got)
351	}
352	// Nothing staged: the platform is not called again.
353	if err := r.applyInputRects(set); err != nil || len(got) != 1 {
354		t.Fatalf("restaged: %v %+v", err, got)
355	}
356	// nil restores the full surface; empty non-nil is click-through.
357	var nilSeen, emptySeen bool
358	r.Redraw()
359	r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects(nil) })
360	_ = r.applyInputRects(func(rs []wayland.Rect) error { nilSeen = rs == nil; return nil })
361	r.Redraw()
362	r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{}) })
363	_ = r.applyInputRects(func(rs []wayland.Rect) error { emptySeen = rs != nil && len(rs) == 0; return nil })
364	if !nilSeen || !emptySeen {
365		t.Fatalf("nil %v empty %v", nilSeen, emptySeen)
366	}
367	// Invalid rects are rejected in the frame call and stage nothing.
368	r.Redraw()
369	for _, bad := range []Rect{{0, 0, 0, 5}, {0, 0, 5, -1}, {math.MaxInt32, 0, 5, 5}} {
370		r.Build(func(f *Frame) { f.Root(); setErr = f.SetInputRects([]Rect{bad}) })
371		if setErr == nil || r.inputStaged {
372			t.Fatalf("accepted %+v", bad)
373		}
374		r.Redraw()
375	}
376	// Steady-state staging and apply do not allocate once buffers exist.
377	r.Build(func(f *Frame) {
378		f.Root()
379		if n := testing.AllocsPerRun(50, func() { _ = f.SetInputRects(rects) }); n != 0 {
380			t.Fatalf("SetInputRects allocs %v", n)
381		}
382	})
383	noop := func([]wayland.Rect) error { return nil }
384	r.inputStaged = true
385	if n := testing.AllocsPerRun(50, func() { r.inputStaged = true; _ = r.applyInputRects(noop) }); n != 0 {
386		t.Fatalf("applyInputRects allocs %v", n)
387	}
388}
389
390// A failed stage keeps the region pending so the next frame retries it.
391func TestStagedInputRectsRetainedOnFailure(t *testing.T) {
392	r := newRuntime()
393	r.Build(func(f *Frame) { f.Root(); _ = f.SetInputRects([]Rect{{1, 2, 3, 4}}) })
394	boom := errors.New("stage failed")
395	if err := r.applyInputRects(func([]wayland.Rect) error { return boom }); !errors.Is(err, boom) || !r.inputStaged {
396		t.Fatalf("failure: %v staged=%v", err, r.inputStaged)
397	}
398	var got []wayland.Rect
399	if err := r.applyInputRects(func(rs []wayland.Rect) error { got = append(got, rs...); return nil }); err != nil || r.inputStaged {
400		t.Fatalf("retry: %v staged=%v", err, r.inputStaged)
401	}
402	if len(got) != 1 || got[0] != (wayland.Rect{X: 1, Y: 2, W: 3, H: 4}) {
403		t.Fatalf("retried region %+v", got)
404	}
405	// A later frame that does not call SetInputRects stages nothing new.
406	r.Redraw()
407	r.Build(func(f *Frame) { f.Root() })
408	called := false
409	_ = r.applyInputRects(func([]wayland.Rect) error { called = true; return nil })
410	if called {
411		t.Fatal("unchanged frame restaged the region")
412	}
413}
414
415func TestNodeRectOutsideStackIsIgnored(t *testing.T) {
416	r := newRuntime()
417	r.width, r.height = 200, 100
418	r.Build(func(f *Frame) {
419		col := f.Root().Column()
420		col.Box(Key("a"), Inline("height:10px")).Rect(50, 50, 5, 5)
421		col.Box(Key("b"), Inline("height:10px"))
422	})
423	kids := r.output.Tree.Children[0].Children
424	if kids[0].Border.X != 0 || kids[0].Border.H != 10 || kids[1].Border.Y != 10 {
425		t.Fatalf("Rect changed normal flow: %+v %+v", kids[0].Border, kids[1].Border)
426	}
427}
428
429// The absolute geometry path adds no allocations over the same tree without it.
430func TestNodeRectAddsNoAllocations(t *testing.T) {
431	build := func(withRect bool) func() {
432		r := newRuntime()
433		r.width, r.height = 640, 480
434		view := func(f *Frame) {
435			st := f.Root().Stack()
436			for i := 0; i < 32; i++ {
437				b := st.Box(Key(strconv.Itoa(i)), Inline("background:#fff"))
438				if withRect {
439					b.Rect(float64(i), float64(i), 20, 20)
440				}
441			}
442		}
443		r.Build(view)
444		return func() { r.Redraw(); r.Build(view) }
445	}
446	plain := testing.AllocsPerRun(20, build(false))
447	rect := testing.AllocsPerRun(20, build(true))
448	if rect > plain {
449		t.Fatalf("Rect added allocations: %v vs %v", rect, plain)
450	}
451}

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