v0.3.0 da0887c

input_test.go RawHistoryFolder

1package bar
2
3import (
4	"log/slog"
5	"slices"
6	"testing"
7
8	"github.com/bnema/neferclient"
9
10	"github.com/bnema/neferbar/internal/layout"
11	"github.com/bnema/neferbar/internal/module"
12	"github.com/bnema/neferbar/internal/racecheck"
13)
14
15// inputRig is a 30-column layout: "a" (interactive) at columns 0-3, "b" (not)
16// at 4-7, both in the left zone.
17type inputRig struct {
18	lay  *layout.Layout
19	a, b *module.Module
20}
21
22func newInputRig(t testing.TB) *inputRig {
23	t.Helper()
24	wake := make(chan struct{}, 1)
25	log := slog.New(slog.DiscardHandler)
26	a := module.New("a", module.Left, "true", wake, log)
27	b := module.New("b", module.Left, "true", wake, log)
28	a.Interactive = true
29	lay := layout.New(30, [3]uint8{1, 1, 1}, [3]uint8{2, 2, 2}, [16][3]uint8{}, []*module.Module{a, b})
30	module.PublishForTest(a, []byte("AAAA"))
31	module.PublishForTest(b, []byte("BBBB"))
32	lay.Update()
33	lay.Compose()
34	return &inputRig{lay: lay, a: a, b: b}
35}
36
37// drain returns the lines queued on m and empties its queue.
38func drain(m *module.Module) []string {
39	return module.DrainForTest(m)
40}
41
42func TestHoverAndLeave(t *testing.T) {
43	r := newInputRig(t)
44	var p pointerState
45	p.motion(r.lay, 2)
46	p.motion(r.lay, 2) // same column: nothing
47	p.motion(r.lay, 3)
48	p.motion(r.lay, 5) // over the non-interactive module: leaves a
49	p.motion(r.lay, 6) // still nothing
50	p.motion(r.lay, 1)
51	p.leave()
52	p.leave()
53	want := []string{"hover 2", "hover 3", "leave", "hover 1", "leave"}
54	if got := drain(r.a); !slices.Equal(got, want) {
55		t.Fatalf("lines = %q, want %q", got, want)
56	}
57	if got := drain(r.b); len(got) != 0 {
58		t.Fatalf("a non-interactive module got %q", got)
59	}
60}
61
62func TestClickTokensAndButtons(t *testing.T) {
63	r := newInputRig(t)
64	var p pointerState
65	p.button(r.lay, 1, btnLeft, 7)
66	p.button(r.lay, 2, btnMiddle, 8)
67	p.button(r.lay, 3, btnRight, 9)
68	p.button(r.lay, 3, 0x113, 10) // another button: ignored
69	p.button(r.lay, 5, btnLeft, 11)
70	p.button(r.lay, 20, btnLeft, 12) // empty bar space
71	want := []string{"click left 1 1", "click middle 2 2", "click right 3 3"}
72	if got := drain(r.a); !slices.Equal(got, want) {
73		t.Fatalf("lines = %q, want %q", got, want)
74	}
75	if p.press.token != 3 || p.press.serial != 9 || p.press.at.IsZero() {
76		t.Fatalf("press = %+v", p.press)
77	}
78}
79
80func TestScrollLines(t *testing.T) {
81	r := newInputRig(t)
82	var p pointerState
83	p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
84	p.axis(r.lay, 1, neferclient.AxisVertical, -240, 0)
85	p.axis(r.lay, 2, neferclient.AxisHorizontal, 120, 0)
86	p.axis(r.lay, 2, neferclient.AxisHorizontal, -120, 0)
87	p.axis(r.lay, 2, neferclient.AxisVertical, 0, 100) // continuous after a wheel: ignored
88	p.axis(r.lay, 6, neferclient.AxisVertical, 120, 0) // not interactive
89	want := []string{"scroll down 1 1", "scroll up 2 1", "scroll right 1 2", "scroll left 1 2"}
90	if got := drain(r.a); !slices.Equal(got, want) {
91		t.Fatalf("lines = %q, want %q", got, want)
92	}
93}
94
95func TestWheelAccumulator(t *testing.T) {
96	var a wheelAcc
97	for _, c := range []struct {
98		axis uint32
99		v120 int32
100		d    float64
101		want int32
102	}{
103		{neferclient.AxisVertical, 120, 0, 1},
104		{neferclient.AxisVertical, 240, 0, 2},
105		{neferclient.AxisVertical, -120, 0, -1},
106		{neferclient.AxisVertical, 60, 0, 0},
107		{neferclient.AxisVertical, 60, 0, 1}, // two half notches
108		{neferclient.AxisHorizontal, 120, 0, 1},
109		{neferclient.AxisVertical, 0, 50, 0}, // continuous after a wheel is ignored
110	} {
111		if got := a.add(c.axis, c.v120, c.d); got != c.want {
112			t.Errorf("add(%d, %d, %v) = %d, want %d", c.axis, c.v120, c.d, got, c.want)
113		}
114	}
115	a.reset()
116	for _, c := range []struct {
117		d    float64
118		want int32
119	}{{15, 1}, {10, 0}, {5, 1}, {-30, -2}, {7, 0}} {
120		if got := a.add(neferclient.AxisVertical, 0, c.d); got != c.want {
121			t.Errorf("continuous %v = %d, want %d", c.d, got, c.want)
122		}
123	}
124	if a.add(2, 120, 0) != 0 {
125		t.Error("an unknown axis must be ignored")
126	}
127}
128
129func TestInputRect(t *testing.T) {
130	// Scale 1.6, 13 px cells: columns 2..4 are physical px 26..65, logical
131	// 16.25..40.625, rounded outward.
132	got := inputRect(2, 3, 13, 1.6, 24)
133	want := neferclient.Rect{X: 16, Y: 0, Width: 25, Height: 24}
134	if got != want {
135		t.Fatalf("rect = %+v, want %+v", got, want)
136	}
137	if got = inputRect(0, 4, 10, 1, 20); got != (neferclient.Rect{X: 0, Y: 0, Width: 40, Height: 20}) {
138		t.Fatalf("exact rect = %+v", got)
139	}
140	if got = inputRect(3, 2, 10, 2, 20); got != (neferclient.Rect{X: 15, Y: 0, Width: 10, Height: 20}) {
141		t.Fatalf("scale 2 rect = %+v", got)
142	}
143}
144
145func TestInputRegionUpdates(t *testing.T) {
146	r := newInputRig(t)
147	var in inputRegion
148	key := inputKey{cellW: 10, scale: 1, height: 20}
149	rects, changed := in.update(r.lay, key)
150	want := []neferclient.Rect{{X: 0, Y: 0, Width: 40, Height: 20}}
151	if !changed || !slices.Equal(rects, want) {
152		t.Fatalf("first update = %+v, %v; want %+v", rects, changed, want)
153	}
154	if _, changed = in.update(r.lay, key); changed {
155		t.Fatal("same spans must not change the region")
156	}
157	if _, changed = in.update(r.lay, inputKey{cellW: 10, scale: 2, height: 20}); !changed {
158		t.Fatal("a scale change must change the region")
159	}
160	in.reset()
161	if _, changed = in.update(r.lay, key); !changed {
162		t.Fatal("a new surface needs the region again")
163	}
164	// No interactive module left: back to an empty, non-nil region.
165	r.a.Interactive = false
166	rects, changed = in.update(r.lay, key)
167	if !changed || rects == nil || len(rects) != 0 {
168		t.Fatalf("empty update = %#v, %v; want non-nil empty", rects, changed)
169	}
170	if _, changed = in.update(r.lay, key); changed {
171		t.Fatal("staying empty must not change the region")
172	}
173}
174
175func TestPointerAllocs(t *testing.T) {
176	if racecheck.Enabled {
177		t.Skip("the race detector allocates")
178	}
179	r := newInputRig(t)
180	var p pointerState
181	cols := [...]int{1, 2, 3, 5, 9}
182	i := 0
183	if got := testing.AllocsPerRun(200, func() {
184		p.motion(r.lay, cols[i%len(cols)])
185		i++
186	}); got > 0 {
187		t.Errorf("motion allocates %.1f objects; want 0", got)
188	}
189	if got := testing.AllocsPerRun(200, func() {
190		p.button(r.lay, 1, btnLeft, 1)
191		p.axis(r.lay, 1, neferclient.AxisVertical, 120, 0)
192		p.leave()
193	}); got > 0 {
194		t.Errorf("click/scroll/leave allocate %.1f objects; want 0", got)
195	}
196	var in inputRegion
197	key := inputKey{cellW: 10, scale: 1, height: 20}
198	in.update(r.lay, key)
199	if got := testing.AllocsPerRun(200, func() { in.update(r.lay, key) }); got > 0 {
200		t.Errorf("an unchanged input region allocates %.1f objects; want 0", got)
201	}
202}
203
204func TestRecreatingTheSurfaceResetsPointerState(t *testing.T) {
205	r := newInputRig(t)
206	b := &Bar{lay: r.lay}
207	b.ptr.motion(r.lay, 2)
208	b.ptr.wheel.add(neferclient.AxisVertical, 120, 0)
209	b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20})
210	drain(r.a)
211
212	b.resetInput()
213
214	if got := drain(r.a); !slices.Equal(got, []string{"leave"}) {
215		t.Fatalf("lines = %q, want one leave", got)
216	}
217	if b.ptr.hover != nil || b.ptr.wheel != (wheelAcc{}) {
218		t.Fatalf("pointer state kept: hover %v, wheel %+v", b.ptr.hover, b.ptr.wheel)
219	}
220	if _, changed := b.input.update(r.lay, inputKey{cellW: 10, scale: 1, height: 20}); !changed {
221		t.Fatal("the new surface needs its input region again")
222	}
223}
224
225// A failed SetInputRegion resets the tracking, so the next draw tries again.
226func TestFailedInputRegionIsRetried(t *testing.T) {
227	r := newInputRig(t)
228	var in inputRegion
229	key := inputKey{cellW: 10, scale: 1, height: 20}
230	if _, changed := in.update(r.lay, key); !changed {
231		t.Fatal("first update must change")
232	}
233	in.reset() // what syncInputRegion does when the request fails
234	if _, changed := in.update(r.lay, key); !changed {
235		t.Fatal("after a failure the same region must be sent again")
236	}
237}
238
239func TestAxisStopClearsTheTouchpadRemainder(t *testing.T) {
240	r := newInputRig(t)
241	b := &Bar{lay: r.lay, sid: 1}
242	axis := func(kind neferclient.PointerKind, ax uint32, dy float64) {
243		b.handlePointer(&neferclient.PointerEvent{Surface: 1, Kind: kind, X: 1, Axis: ax, DY: dy}, 1, 10)
244	}
245	axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // 10 of 15 px: no step yet
246	axis(neferclient.PointerAxisStop, neferclient.AxisVertical, 0)
247	axis(neferclient.PointerAxis, neferclient.AxisVertical, 10) // would complete a step if 10 were kept
248	if got := drain(r.a); len(got) != 0 {
249		t.Fatalf("lines = %q; the remainder crossed a gesture", got)
250	}
251	axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
252	if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
253		t.Fatalf("lines = %q, want one step", got)
254	}
255	// Stopping the other axis leaves this one alone.
256	axis(neferclient.PointerAxis, neferclient.AxisVertical, 10)
257	axis(neferclient.PointerAxisStop, neferclient.AxisHorizontal, 0)
258	axis(neferclient.PointerAxis, neferclient.AxisVertical, 5)
259	if got := drain(r.a); !slices.Equal(got, []string{"scroll down 1 0"}) {
260		t.Fatalf("lines = %q; the other axis's stop cleared this one", got)
261	}
262	var a wheelAcc
263	a.stop(7) // unknown axis: no panic
264}
265
266func TestRestartedScriptGetsNoLeaveAndAHoverAgain(t *testing.T) {
267	r := newInputRig(t)
268	var p pointerState
269	p.motion(r.lay, 1)
270	if got := drain(r.a); !slices.Equal(got, []string{"hover 1"}) {
271		t.Fatalf("lines = %q", got)
272	}
273	// The script restarts: the new process knows nothing of the hover.
274	module.SetGenForTest(r.a, 7)
275	p.motion(r.lay, 5) // moves off the module: the new process gets no leave
276	if got := drain(r.a); len(got) != 0 {
277		t.Fatalf("lines = %q, want no leave for the new process", got)
278	}
279	// The same move onto the module again sends it a hover.
280	p.motion(r.lay, 1)
281	module.SetGenForTest(r.a, 8)
282	p.motion(r.lay, 1) // still over it, but the script restarted: hover again
283	if got := drain(r.a); !slices.Equal(got, []string{"hover 1", "hover 1"}) {
284		t.Fatalf("lines = %q, want a fresh hover after the restart", got)
285	}
286
287	// leave after a restart sends nothing to the new process.
288	p.motion(r.lay, 2)
289	drain(r.a)
290	module.SetGenForTest(r.a, 9)
291	p.leave()
292	if got := drain(r.a); len(got) != 0 {
293		t.Fatalf("lines = %q, want none", got)
294	}
295	if p.hover != nil {
296		t.Fatal("hover not cleared")
297	}
298
299	// Without a restart nothing changes.
300	p.motion(r.lay, 2)
301	p.motion(r.lay, 6)
302	if got := drain(r.a); !slices.Equal(got, []string{"hover 2", "leave"}) {
303		t.Fatalf("lines = %q", got)
304	}
305	if got := testing.AllocsPerRun(100, func() { p.motion(r.lay, 1); p.motion(r.lay, 6) }); got > 0 && !racecheck.Enabled {
306		t.Errorf("motion allocates %.1f", got)
307	}
308}
309
310func TestCellColumnAtFractionalScale(t *testing.T) {
311	// Scale 1.6, 13 px cells: a cell is 8.125 logical px wide.
312	for _, c := range []struct {
313		x    float64
314		want int
315	}{{0, 0}, {8.1, 0}, {8.125, 1}, {16.24, 1}, {16.25, 2}, {40.6, 4}, {40.625, 5}, {-0.1, -1}} {
316		if got := cellColumn(c.x, 1.6, 13); got != c.want {
317			t.Errorf("cellColumn(%v) = %d, want %d", c.x, got, c.want)
318		}
319	}
320}
321
322func TestPointerFiltersSurfaceAndUsesFractionalScale(t *testing.T) {
323	r := newInputRig(t) // a: columns 0-3, b: 4-7
324	b := &Bar{lay: r.lay, sid: 5}
325	// Another surface (a popup): ignored before the bar's size is even read.
326	b.Pointer(&neferclient.PointerEvent{Surface: 9, Kind: neferclient.PointerMotion, X: 1})
327	if got := drain(r.a); len(got) != 0 || b.ptr.hover != nil {
328		t.Fatalf("a foreign surface moved the hover: %q", got)
329	}
330	// Logical x 30 at scale 1.6 and 13 px cells is column 3 (physical px 48).
331	b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 30}, 1.6, 13)
332	// Logical x 33 is column 4: the non-interactive module, so a leave.
333	b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 33}, 1.6, 13)
334	b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerMotion, X: 8.2}, 1.6, 13)
335	b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: true, X: 8.2}, 1.6, 13)
336	b.handlePointer(&neferclient.PointerEvent{Surface: 5, Kind: neferclient.PointerButton, Button: btnRight, Pressed: false, X: 8.2}, 1.6, 13)
337	want := []string{"hover 3", "leave", "hover 1", "click right 1 1"}
338	if got := drain(r.a); !slices.Equal(got, want) {
339		t.Fatalf("lines = %q, want %q", got, want)
340	}
341}

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 5

Tags 4

↑↓ moveenter openesc close