keyboard_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"os"
6	"os/exec"
7	"regexp"
8	"slices"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/bnema/go-wayland-bindings/server/wayland"
14	"github.com/bnema/neferwl/internal/adapters/logging"
15	"github.com/bnema/neferwl/internal/ports"
16	"github.com/bnema/wlturbo"
17	"golang.org/x/sys/unix"
18)
19
20func keymapText(t *testing.T) string {
21	t.Helper()
22	data, err := os.ReadFile("testdata/us.xkb")
23	if err != nil {
24		t.Fatal(err)
25	}
26	return string(data)
27}
28
29// keyboardServer runs a server; setup, if any, runs before Run starts.
30func keyboardServer(t *testing.T, setup ...func(*Server)) (*Server, chan ports.ClientEvent, chan ports.ClientCommand, string) {
31	t.Helper()
32	dir := t.TempDir()
33	events := make(chan ports.ClientEvent, 16)
34	commands := make(chan ports.ClientCommand, 32)
35	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, Keymap: keymapText(t), RepeatRate: 25, RepeatDelay: 600}, Channels{Events: events, Commands: commands}, logging.For(context.Background(), "wayland"))
36	if err != nil {
37		t.Fatal(err)
38	}
39	for _, f := range setup {
40		f(s)
41	}
42	ctx, cancel := context.WithCancel(context.Background())
43	done := make(chan error, 1)
44	go func() { done <- s.Run(ctx) }()
45	t.Cleanup(func() {
46		cancel()
47		select {
48		case err := <-done:
49			if err != nil {
50				t.Errorf("Run: %v", err)
51			}
52		case <-time.After(2 * time.Second):
53			t.Error("Run hung")
54		}
55	})
56	return s, events, commands, dir
57}
58
59func TestKeyboardInfo(t *testing.T) {
60	tool, err := exec.LookPath("wayland-info")
61	if err != nil {
62		t.Skip("wayland-info not installed")
63	}
64	s, _, _, dir := keyboardServer(t)
65	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
66	defer cancel()
67	cmd := exec.CommandContext(ctx, tool)
68	cmd.Env = append(os.Environ(), "XDG_RUNTIME_DIR="+dir, "WAYLAND_DISPLAY="+s.SocketName())
69	out, err := cmd.CombinedOutput()
70	if err != nil {
71		t.Fatalf("wayland-info: %v: %s", err, out)
72	}
73	for _, want := range []string{"capabilities: pointer keyboard", "keyboard repeat rate: 25", "keyboard repeat delay: 600"} {
74		if !strings.Contains(string(out), want) {
75			t.Errorf("missing %q: %s", want, out)
76		}
77	}
78}
79
80type keymapProxy struct {
81	wlturbo.BaseProxy
82	result chan error
83}
84
85func (p *keymapProxy) Dispatch(e *wlturbo.Event) {
86	if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
87		return
88	}
89	format := e.Uint32()
90	fd := int(e.Fd())
91	size := e.Uint32()
92	if fd <= 0 || size == 0 || format != uint32(wayland.KeyboardKeymapFormatXkbV1) {
93		p.result <- os.ErrInvalid
94		return
95	}
96	defer unix.Close(fd)
97	data := make([]byte, size)
98	_, err := unix.Pread(fd, data, 0)
99	if err == nil && !strings.HasPrefix(string(data), "xkb_keymap") {
100		err = os.ErrInvalid
101	}
102	p.result <- err
103}
104func TestKeyboardKeymapProtocol(t *testing.T) {
105	s, _, _, dir := keyboardServer(t)
106	c := protocolClient(t, s, dir)
107	seat := bindProtocol(t, c, "wl_seat")
108	registerProtocol(t, c, seat)
109	id := c.AllocateID()
110	proxy := &keymapProxy{result: make(chan error, 1)}
111	proxy.SetID(id)
112	registerWireProxy(c, proxy)
113	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
114	if err := c.Roundtrip(); err != nil {
115		t.Fatal(err)
116	}
117	select {
118	case err := <-proxy.result:
119		if err != nil {
120			t.Fatal(err)
121		}
122	default:
123		t.Fatal("missing keymap event")
124	}
125}
126
127// events records keyboard event opcodes; a keymap event also checks its content.
128type eventsProxy struct {
129	wlturbo.BaseProxy
130	opcodes []uint16
131	keymaps []string
132	locked  []uint32      // locked modifiers of each modifiers event
133	depress []uint32      // depressed modifiers of each modifiers event
134	repeat  chan struct{} // optional protocol barrier notification
135}
136
137func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
138	p.opcodes = append(p.opcodes, e.Opcode)
139	if e.Opcode == uint16(wayland.KeyboardEventRepeatInfo) && p.repeat != nil {
140		p.repeat <- struct{}{}
141	}
142	if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
143		e.Uint32() // serial
144		p.depress = append(p.depress, e.Uint32())
145		e.Uint32() // latched
146		p.locked = append(p.locked, e.Uint32())
147		return
148	}
149	if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
150		return
151	}
152	e.Uint32()
153	fd := int(e.Fd())
154	size := e.Uint32()
155	defer unix.Close(fd)
156	data := make([]byte, size)
157	if _, err := unix.Pread(fd, data, 0); err == nil {
158		p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
159	}
160}
161
162func TestSetKeymap(t *testing.T) {
163	s, _, commands, dir := keyboardServer(t)
164	c := protocolClient(t, s, dir)
165	// Version 4 so the keyboard receives repeat_info.
166	g, ok := c.Registry().FindGlobal("wl_seat")
167	if !ok {
168		t.Fatal("missing wl_seat")
169	}
170	seat, err := bindWireID(c, g.Name, g.Interface, 4)
171	if err != nil {
172		t.Fatal(err)
173	}
174	registerProtocol(t, c, seat)
175	id := c.AllocateID()
176	proxy := &eventsProxy{}
177	proxy.SetID(id)
178	registerWireProxy(c, proxy)
179	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
180	if err := c.Roundtrip(); err != nil {
181		t.Fatal(err)
182	}
183	proxy.opcodes, proxy.keymaps = nil, nil
184	sync := func() {
185		t.Helper()
186		// Commands apply on the display goroutine; wait until it has run them.
187		deadline := time.Now().Add(time.Second)
188		for len(commands) > 0 && time.Now().Before(deadline) {
189			time.Sleep(5 * time.Millisecond)
190		}
191		time.Sleep(20 * time.Millisecond)
192		if err := c.Roundtrip(); err != nil {
193			t.Fatal(err)
194		}
195	}
196
197	// Repeat-only: one repeat_info, no keymap.
198	commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
199	sync()
200	if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
201		t.Fatalf("repeat-only events %v", proxy.opcodes)
202	}
203
204	// New keymap: keymap then repeat_info, and the new content.
205	proxy.opcodes = nil
206	next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
207	commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
208	sync()
209	want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
210	if !slices.Equal(proxy.opcodes, want) {
211		t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
212	}
213	if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
214		t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
215	}
216	// A keyboard created afterwards gets the new keymap.
217	id2 := c.AllocateID()
218	late := &eventsProxy{}
219	late.SetID(id2)
220	registerWireProxy(c, late)
221	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
222	if err := c.Roundtrip(); err != nil {
223		t.Fatal(err)
224	}
225	if len(late.keymaps) != 1 || late.keymaps[0] != next {
226		t.Fatal("late keyboard got the old keymap")
227	}
228}
229
230func TestFootKeyboard(t *testing.T) {
231	tool, err := exec.LookPath("foot")
232	if err != nil {
233		t.Skip("foot not installed")
234	}
235	s, events, commands, dir := keyboardServer(t)
236	_, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "WAYLAND_DEBUG=client"}, "-c", "/dev/null", "sh", "-c", "sleep 10")
237	w := mapped(t, events, 10*time.Second)
238	commands <- ports.FocusWindow{ID: w.ID}
239	for _, code := range []uint32{18, 46, 35, 24, 57, 35, 23, 28} {
240		commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: code, Pressed: true, Time: 1 * time.Millisecond}}
241		commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: code, Time: 2 * time.Millisecond}}
242	}
243	enter := regexp.MustCompile(`wl_keyboard#[0-9]+\.enter\(`)
244	key := regexp.MustCompile(`wl_keyboard#[0-9]+\.key\(`)
245	deadline := time.After(4 * time.Second)
246	tick := time.NewTicker(10 * time.Millisecond)
247	defer tick.Stop()
248	for {
249		select {
250		case err := <-done:
251			t.Fatalf("foot exited: %v: %s", err, output.String())
252		case <-deadline:
253			t.Fatalf("keyboard events missing: %s", output.String())
254		case <-tick.C:
255			text := output.String()
256			if enter.MatchString(text) && key.MatchString(text) {
257				return
258			}
259		}
260	}
261}
262
263// SetModifiers sends the requested depressed mask with the real latched and
264// locked state, and Restore sends the real state back; the seat's own state
265// is untouched. A window that is not focused is ignored.
266func TestSetModifiersOverrideAndRestore(t *testing.T) {
267	s, events, commands, dir := keyboardServer(t)
268	target, proxy := focusedTarget(t, s, events, commands, dir)
269	const capsLocked = 2
270	var id ports.WindowID
271	set := make(chan struct{})
272	s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
273	<-set
274	wait := func() {
275		t.Helper()
276		syncCommands(t, s, commands, target)
277	}
278
279	proxy.depress, proxy.locked = nil, nil
280	commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
281	wait()
282	if len(proxy.depress) != 0 {
283		t.Fatalf("unfocused window got modifiers %v", proxy.depress)
284	}
285	commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
286	wait()
287	if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
288		t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
289	}
290	done := make(chan ports.ModState, 1)
291	commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
292	wait()
293	if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
294		t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
295	}
296	s.display.Do(func() { done <- s.seat.modState })
297	if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
298		t.Fatalf("seat state changed: %+v", m)
299	}
300}
301
302// Restore carries the real state core saw, which swallowed keys (a latch a
303// hint label key consumed) never gave the seat: it is sent and adopted.
304func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
305	s, events, commands, dir := keyboardServer(t)
306	target, proxy := focusedTarget(t, s, events, commands, dir)
307	const shiftLatched, capsLocked = 1, 2
308	var id ports.WindowID
309	set := make(chan struct{})
310	s.display.Do(func() { s.seat.modState = ports.ModState{Latched: shiftLatched}; id = s.seat.focused; close(set) })
311	<-set
312	proxy.depress, proxy.locked = nil, nil
313	want := ports.ModState{Locked: capsLocked} // the latch was consumed
314	commands <- ports.SetModifiers{ID: id, Restore: true, State: want}
315	var got ports.ModState
316	deadline := time.Now().Add(2 * time.Second)
317	for got != want {
318		if time.Now().After(deadline) {
319			t.Fatalf("seat state %+v, want %+v", got, want)
320		}
321		s.display.Do(func() { got = s.seat.modState })
322		time.Sleep(time.Millisecond)
323	}
324	roundtrip(t, target)
325	if !slices.Equal(proxy.locked, []uint32{capsLocked}) || !slices.Equal(proxy.depress, []uint32{0}) {
326		t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
327	}
328}
329
330// syncCommands observes an existing command through the real protocol. A
331// repeat_info emitted by SetKeymap means all preceding commands were applied;
332// merely emptying the channel or posting a display callback cannot prove that.
333func syncCommands(t *testing.T, s *Server, commands chan<- ports.ClientCommand, c *wlturbo.Display) {
334	t.Helper()
335	var needsKeymap bool
336	if !s.display.Do(func() { needsKeymap = s.seat.keymapFD < 0 }) {
337		t.Fatal("display stopped")
338	}
339	if needsKeymap {
340		keymap := keymapText(t)
341		if !s.display.Do(func() { s.setKeymap(ports.SetKeymap{Keymap: keymap}) }) {
342			t.Fatal("display stopped")
343		}
344	}
345	seat := bindVersion(t, c, "wl_seat", 4)
346	registerProtocol(t, c, seat)
347	id := c.AllocateID()
348	p := &eventsProxy{repeat: make(chan struct{}, 2)}
349	p.SetID(id)
350	registerWireProxy(c, p)
351	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
352	roundtrip(t, c)
353	select {
354	case <-p.repeat:
355	default:
356		t.Fatal("missing initial repeat_info")
357	}
358	var rate, delay int
359	if !s.display.Do(func() { rate, delay = s.seat.repeatRate, s.seat.repeatDelay }) {
360		t.Fatal("display stopped")
361	}
362	commands <- ports.SetKeymap{RepeatRate: rate, RepeatDelay: delay}
363	// Dispatch blocks on the protocol socket; close it on timeout to bound
364	// failure without polling the display or sleeping for command delivery.
365	timeout := time.AfterFunc(2*time.Second, func() { _ = c.Close() })
366	defer timeout.Stop()
367	for {
368		select {
369		case <-p.repeat:
370			requestProtocol(t, c, id, wayland.KeyboardRequestRelease)
371			roundtrip(t, c)
372			return
373		default:
374		}
375		if err := c.Dispatch(); err != nil {
376			t.Fatal(err)
377		}
378	}
379}

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 23

Tags 10

↑↓ moveenter openesc close