feat/hints a471fa4

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}
135
136func (p *eventsProxy) Dispatch(e *wlturbo.Event) {
137	p.opcodes = append(p.opcodes, e.Opcode)
138	if e.Opcode == uint16(wayland.KeyboardEventModifiers) {
139		e.Uint32() // serial
140		p.depress = append(p.depress, e.Uint32())
141		e.Uint32() // latched
142		p.locked = append(p.locked, e.Uint32())
143		return
144	}
145	if e.Opcode != uint16(wayland.KeyboardEventKeymap) {
146		return
147	}
148	e.Uint32()
149	fd := int(e.Fd())
150	size := e.Uint32()
151	defer unix.Close(fd)
152	data := make([]byte, size)
153	if _, err := unix.Pread(fd, data, 0); err == nil {
154		p.keymaps = append(p.keymaps, strings.TrimRight(string(data), "\x00"))
155	}
156}
157
158func TestSetKeymap(t *testing.T) {
159	s, _, commands, dir := keyboardServer(t)
160	c := protocolClient(t, s, dir)
161	// Version 4 so the keyboard receives repeat_info.
162	g, ok := c.Registry().FindGlobal("wl_seat")
163	if !ok {
164		t.Fatal("missing wl_seat")
165	}
166	seat, err := bindWireID(c, g.Name, g.Interface, 4)
167	if err != nil {
168		t.Fatal(err)
169	}
170	registerProtocol(t, c, seat)
171	id := c.AllocateID()
172	proxy := &eventsProxy{}
173	proxy.SetID(id)
174	registerWireProxy(c, proxy)
175	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id)
176	if err := c.Roundtrip(); err != nil {
177		t.Fatal(err)
178	}
179	proxy.opcodes, proxy.keymaps = nil, nil
180	sync := func() {
181		t.Helper()
182		// Commands apply on the display goroutine; wait until it has run them.
183		deadline := time.Now().Add(time.Second)
184		for len(commands) > 0 && time.Now().Before(deadline) {
185			time.Sleep(5 * time.Millisecond)
186		}
187		time.Sleep(20 * time.Millisecond)
188		if err := c.Roundtrip(); err != nil {
189			t.Fatal(err)
190		}
191	}
192
193	// Repeat-only: one repeat_info, no keymap.
194	commands <- ports.SetKeymap{RepeatRate: 40, RepeatDelay: 300}
195	sync()
196	if len(proxy.opcodes) != 1 || proxy.opcodes[0] != uint16(wayland.KeyboardEventRepeatInfo) {
197		t.Fatalf("repeat-only events %v", proxy.opcodes)
198	}
199
200	// New keymap: keymap then repeat_info, and the new content.
201	proxy.opcodes = nil
202	next := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
203	commands <- ports.SetKeymap{Keymap: next, RepeatRate: 40, RepeatDelay: 300}
204	sync()
205	want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventRepeatInfo)}
206	if !slices.Equal(proxy.opcodes, want) {
207		t.Fatalf("keymap events %v, want %v", proxy.opcodes, want)
208	}
209	if len(proxy.keymaps) != 1 || proxy.keymaps[0] != next {
210		t.Fatalf("keymap content not replaced (%d keymaps)", len(proxy.keymaps))
211	}
212	// A keyboard created afterwards gets the new keymap.
213	id2 := c.AllocateID()
214	late := &eventsProxy{}
215	late.SetID(id2)
216	registerWireProxy(c, late)
217	requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, id2)
218	if err := c.Roundtrip(); err != nil {
219		t.Fatal(err)
220	}
221	if len(late.keymaps) != 1 || late.keymaps[0] != next {
222		t.Fatal("late keyboard got the old keymap")
223	}
224}
225
226func TestFootKeyboard(t *testing.T) {
227	tool, err := exec.LookPath("foot")
228	if err != nil {
229		t.Skip("foot not installed")
230	}
231	s, events, commands, dir := keyboardServer(t)
232	_, done, output := lifecycleClient(t, tool, s, dir, []string{"HOME=" + t.TempDir(), "WAYLAND_DEBUG=client"}, "-c", "/dev/null", "sh", "-c", "sleep 10")
233	w := mapped(t, events, 10*time.Second)
234	commands <- ports.FocusWindow{ID: w.ID}
235	for _, code := range []uint32{18, 46, 35, 24, 57, 35, 23, 28} {
236		commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: code, Pressed: true, Time: 1 * time.Millisecond}}
237		commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: code, Time: 2 * time.Millisecond}}
238	}
239	enter := regexp.MustCompile(`wl_keyboard#[0-9]+\.enter\(`)
240	key := regexp.MustCompile(`wl_keyboard#[0-9]+\.key\(`)
241	deadline := time.After(4 * time.Second)
242	tick := time.NewTicker(10 * time.Millisecond)
243	defer tick.Stop()
244	for {
245		select {
246		case err := <-done:
247			t.Fatalf("foot exited: %v: %s", err, output.String())
248		case <-deadline:
249			t.Fatalf("keyboard events missing: %s", output.String())
250		case <-tick.C:
251			text := output.String()
252			if enter.MatchString(text) && key.MatchString(text) {
253				return
254			}
255		}
256	}
257}
258
259// SetModifiers sends the requested depressed mask with the real latched and
260// locked state, and Restore sends the real state back; the seat's own state
261// is untouched. A window that is not focused is ignored.
262func TestSetModifiersOverrideAndRestore(t *testing.T) {
263	s, events, commands, dir := keyboardServer(t)
264	target, proxy := focusedTarget(t, s, events, commands, dir)
265	const capsLocked = 2
266	var id ports.WindowID
267	set := make(chan struct{})
268	s.display.Do(func() { s.seat.modState = ports.ModState{Locked: capsLocked}; id = s.seat.focused; close(set) })
269	<-set
270	// A marker command applied on the display means every command sent before
271	// it was applied (they apply in order); the roundtrip then delivers the
272	// events they sent.
273	var pending bool
274	wait := func() {
275		t.Helper()
276		pending = !pending
277		commands <- ports.SlotsPending{Pending: pending}
278		deadline := time.Now().Add(2 * time.Second)
279		for {
280			var now bool
281			if !s.display.Do(func() { now = s.slotsPending }) {
282				t.Fatal("display stopped")
283			}
284			if now == pending {
285				break
286			}
287			if time.Now().After(deadline) {
288				t.Fatal("commands not applied")
289			}
290			time.Sleep(time.Millisecond)
291		}
292		roundtrip(t, target)
293	}
294	proxy.depress, proxy.locked = nil, nil
295	commands <- ports.SetModifiers{ID: id + 100, Mods: ports.ModCtrl}
296	wait()
297	if len(proxy.depress) != 0 {
298		t.Fatalf("unfocused window got modifiers %v", proxy.depress)
299	}
300	commands <- ports.SetModifiers{ID: id, Mods: ports.ModCtrl | ports.ModShift}
301	wait()
302	if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift}) || !slices.Equal(proxy.locked, []uint32{capsLocked}) {
303		t.Fatalf("override depressed %v locked %v", proxy.depress, proxy.locked)
304	}
305	done := make(chan ports.ModState, 1)
306	commands <- ports.SetModifiers{ID: id, Restore: true, State: ports.ModState{Locked: capsLocked}}
307	wait()
308	if !slices.Equal(proxy.depress, []uint32{xkbControl | xkbShift, 0}) || !slices.Equal(proxy.locked, []uint32{capsLocked, capsLocked}) {
309		t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
310	}
311	s.display.Do(func() { done <- s.seat.modState })
312	if m := <-done; m != (ports.ModState{Locked: capsLocked}) {
313		t.Fatalf("seat state changed: %+v", m)
314	}
315}
316
317// Restore carries the real state core saw, which swallowed keys (a latch a
318// hint label key consumed) never gave the seat: it is sent and adopted.
319func TestSetModifiersRestoreAdoptsCoreState(t *testing.T) {
320	s, events, commands, dir := keyboardServer(t)
321	target, proxy := focusedTarget(t, s, events, commands, dir)
322	const shiftLatched, capsLocked = 1, 2
323	var id ports.WindowID
324	set := make(chan struct{})
325	s.display.Do(func() { s.seat.modState = ports.ModState{Latched: shiftLatched}; id = s.seat.focused; close(set) })
326	<-set
327	proxy.depress, proxy.locked = nil, nil
328	want := ports.ModState{Locked: capsLocked} // the latch was consumed
329	commands <- ports.SetModifiers{ID: id, Restore: true, State: want}
330	var got ports.ModState
331	deadline := time.Now().Add(2 * time.Second)
332	for got != want {
333		if time.Now().After(deadline) {
334			t.Fatalf("seat state %+v, want %+v", got, want)
335		}
336		s.display.Do(func() { got = s.seat.modState })
337		time.Sleep(time.Millisecond)
338	}
339	roundtrip(t, target)
340	if !slices.Equal(proxy.locked, []uint32{capsLocked}) || !slices.Equal(proxy.depress, []uint32{0}) {
341		t.Fatalf("restore depressed %v locked %v", proxy.depress, proxy.locked)
342	}
343}

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