v0.2.0 6e0cddc

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

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