v0.1.0 0e37717

xkbcommon_test.go RawHistoryFolder

1package xkbcommon
2
3import (
4	"errors"
5	"fmt"
6	"os"
7	"os/exec"
8	"regexp"
9	"strings"
10	"testing"
11	"unsafe"
12
13	"github.com/bnema/purego-xkbcommon/raw"
14)
15
16func live(t *testing.T) *Context {
17	t.Helper()
18	if err := Available(); err != nil {
19		if strings.Contains(err.Error(), "load libxkbcommon.so.0") {
20			t.Skipf("live libxkbcommon.so.0 absent: %v", err)
21		}
22		t.Fatal(err)
23	}
24	c, e := NewContext()
25	if e != nil {
26		t.Fatal(e)
27	}
28	t.Cleanup(func() { c.Close() })
29	return c
30}
31func rules(t *testing.T, c *Context, layout string) *Keymap {
32	t.Helper()
33	k, e := c.NewKeymapRules("evdev", "pc105", layout, "", "")
34	if e != nil {
35		t.Fatalf("rules %s: %v", layout, e)
36	}
37	t.Cleanup(func() { k.Close() })
38	return k
39}
40func state(t *testing.T, k *Keymap) *State {
41	t.Helper()
42	s, e := k.NewState()
43	if e != nil {
44		t.Fatal(e)
45	}
46	t.Cleanup(func() { s.Close() })
47	return s
48}
49func TestABI(t *testing.T) {
50	if unsafe.Sizeof(raw.RuleNames{}) != 5*unsafe.Sizeof(uintptr(0)) {
51		t.Fatal("rule names ABI")
52	}
53	if unsafe.Sizeof(raw.Keycode(0)) != 4 || unsafe.Sizeof(raw.Keysym(0)) != 4 || unsafe.Sizeof(raw.ModMask(0)) != 4 {
54		t.Fatal("scalar ABI")
55	}
56}
57func TestSymbols(t *testing.T) {
58	live(t)
59	if len(raw.Symbols) < 25 {
60		t.Fatal("incomplete required symbol set")
61	}
62}
63func TestMalformedAndLifetime(t *testing.T) {
64	c := live(t)
65	if _, e := c.NewKeymapString("garbage"); e == nil {
66		t.Fatal("malformed keymap compiled")
67	}
68	if _, e := c.NewKeymapString("foo\x00bar"); e == nil {
69		t.Fatal("embedded NUL")
70	}
71	k := rules(t, c, "us")
72	s := state(t, k)
73	c.Close()
74	k.Close()
75	for i := 0; i < 3; i++ {
76		c.Close()
77		k.Close()
78	}
79	if _, e := k.NewState(); !errors.Is(e, ErrClosed) {
80		t.Fatal(e)
81	}
82	v, e := s.UTF8(38)
83	if e != nil || v != "a" {
84		t.Fatalf("state after parent close: %q %v", v, e)
85	}
86	s.Close()
87	s.Close()
88	if _, e := s.KeySym(38); !errors.Is(e, ErrClosed) {
89		t.Fatal(e)
90	}
91}
92func TestFD(t *testing.T) {
93	c := live(t)
94	short := keymapFile(t, []byte("short"))
95	if _, e := c.NewKeymapFD(int(short.Fd()), 4096); e == nil || !strings.Contains(e.Error(), "short read") {
96		t.Fatalf("truncated keymap: %v", e)
97	}
98	if _, e := short.Stat(); e != nil {
99		t.Fatalf("FD was closed on error: %v", e)
100	}
101	for _, size := range []int{0, 16<<20 + 1} {
102		if _, e := c.NewKeymapFD(int(short.Fd()), size); e == nil {
103			t.Fatalf("accepted size %d", size)
104		}
105	}
106	if _, e := c.NewKeymapFD(-1, 5); e == nil {
107		t.Fatal("negative FD")
108	}
109	if _, e := exec.LookPath("xkbcli"); e != nil {
110		t.Skipf("xkbcli compile-keymap reference unavailable: %v", e)
111	}
112	out, e := exec.Command("xkbcli", "compile-keymap", "--rules", "evdev", "--model", "pc105", "--layout", "fr", "--output-format", "1").Output()
113	if e != nil {
114		t.Fatalf("reference xkbcli: %v", e)
115	}
116	f := keymapFile(t, append(out, 0))
117	other, e := c.NewKeymapFD(int(f.Fd()), len(out)+1)
118	if e != nil {
119		t.Fatal(e)
120	}
121	other.Close()
122	if _, e = f.Stat(); e != nil {
123		t.Fatalf("FD was closed: %v", e)
124	}
125	if _, e = c.NewKeymapFD(int(f.Fd()), len(out)+2); e == nil || !strings.Contains(e.Error(), "short read") {
126		t.Fatalf("truncated keymap: %v", e)
127	}
128	if _, e = c.NewKeymapFD(int(f.Fd()), len(out)); e != nil {
129		t.Fatalf("non-NUL text: %v", e)
130	}
131}
132
133func keymapFile(t *testing.T, data []byte) *os.File {
134	t.Helper()
135	f, e := os.CreateTemp(t.TempDir(), "keymap")
136	if e != nil {
137		t.Fatal(e)
138	}
139	t.Cleanup(func() { f.Close() })
140	if _, e = f.Write(data); e != nil {
141		t.Fatal(e)
142	}
143	return f
144}
145
146func TestLoaderDiagnostics(t *testing.T) {
147	_, e := resolveLibrary("/no/such/libxkbcommon.so", raw.Symbols)
148	if e == nil || !strings.Contains(e.Error(), "load /no/such/libxkbcommon.so") {
149		t.Fatalf("missing library: %v", e)
150	}
151	if e := Available(); e != nil {
152		if strings.Contains(e.Error(), "load libxkbcommon.so.0") {
153			t.Skipf("missing symbol test needs libxkbcommon.so.0: %v", e)
154		}
155		t.Fatal(e)
156	}
157	_, e = resolveLibrary("libxkbcommon.so.0", []string{"xkb_nonexistent_test_symbol"})
158	if e == nil || !strings.Contains(e.Error(), "missing xkb_nonexistent_test_symbol") {
159		t.Fatalf("missing symbol: %v", e)
160	}
161}
162func TestLayoutsAndDifferential(t *testing.T) {
163	c := live(t)
164	for _, tc := range []struct {
165		layout string
166		code   uint32
167		want   string
168		alt    bool
169	}{{"us", 38, "a", false}, {"fr", 24, "a", false}, {"de", 29, "z", false}, {"de", 26, "€", true}, {"fr", 26, "€", true}} {
170		t.Run(tc.layout+tc.want, func(t *testing.T) {
171			k := rules(t, c, tc.layout)
172			s := state(t, k)
173			if tc.alt {
174				idx, e := k.ModIndex("LevelThree")
175				if e != nil || idx == ^uint32(0) || idx >= 32 {
176					t.Fatalf("LevelThree: %d %v", idx, e)
177				}
178				s.UpdateMask(1<<idx, 0, 0, 0, 0, 0)
179			}
180			got, e := s.UTF8(tc.code)
181			if e != nil || got != tc.want {
182				t.Fatalf("utf8 %q want %q: %v", got, tc.want, e)
183			}
184			sym, e := s.KeySym(tc.code)
185			if e != nil {
186				t.Fatal(e)
187			}
188			name, e := KeysymName(sym)
189			if e != nil || name == "" {
190				t.Fatalf("name %q: %v", name, e)
191			}
192			back, e := KeysymFromName(name)
193			if e != nil || back != sym {
194				t.Fatalf("name roundtrip %d %d %v", sym, back, e)
195			}
196			// Compare safe calls to the independently resolved native entry points for this real rules keymap.
197			nativeSym := loader.f.Xkb_state_key_get_one_sym(s.h, tc.code)
198			if nativeSym != sym {
199				t.Fatalf("native sym %d != %d", nativeSym, sym)
200			}
201			nativeN := loader.f.Xkb_state_key_get_utf8(s.h, tc.code, 0, 0)
202			if nativeN != int32(len(got)) {
203				t.Fatalf("native bytes %d != %d", nativeN, len(got))
204			}
205		})
206	}
207	k := rules(t, c, "us,de")
208	s := state(t, k)
209	n, e := k.LayoutCount()
210	if e != nil || n != 2 {
211		t.Fatalf("layouts %d %v", n, e)
212	}
213	s.UpdateMask(0, 0, 0, 0, 0, 1)
214	group, e := s.Layout()
215	if e != nil || group != 1 {
216		t.Fatalf("group %d %v", group, e)
217	}
218	v, _ := s.UTF8(29)
219	if v != "z" {
220		t.Fatalf("group 1 %q", v)
221	}
222	shift, e := k.ModIndex("Shift")
223	if e != nil || shift >= 32 {
224		t.Fatalf("shift %d %v", shift, e)
225	}
226	s.UpdateMask(1<<shift, 0, 0, 0, 0, 1)
227	v, _ = s.UTF8(29)
228	if v != "Z" {
229		t.Fatalf("shift group 1 %q", v)
230	}
231	mods, _ := s.Mods()
232	if mods&(1<<shift) == 0 {
233		t.Fatalf("mods %#x", mods)
234	}
235}
236
237// These expectations are fixed evdev/XKB facts, not values computed by the
238// binding under test. Every case compiles through the Wayland FD path.
239func TestFDReferenceKeyEvents(t *testing.T) {
240	c := live(t)
241	for _, tc := range []struct {
242		layout, key, utf8, modifiers string
243		evdev, group, sym            uint32
244	}{
245		{"us", "q", "q", "", 16, 0, raw.XKB_KEY_q},
246		{"us", "Q", "Q", "Shift", 16, 0, raw.XKB_KEY_Q},
247		{"us", "Q", "Q", "Lock", 16, 0, raw.XKB_KEY_Q},
248		{"fr", "a", "a", "", 16, 0, raw.XKB_KEY_a},
249		{"fr", "€", "€", "Mod5", 18, 0, raw.XKB_KEY_EuroSign},
250		{"de", "z", "z", "", 21, 0, raw.XKB_KEY_z},
251		{"de", "@", "@", "Mod5", 16, 0, raw.XKB_KEY_at},
252		{"de", "dead_circumflex", "", "", 41, 0, raw.XKB_KEY_dead_circumflex},
253		{"us,fr", "a", "a", "", 16, 1, raw.XKB_KEY_a},
254	} {
255		t.Run(tc.layout+"/"+tc.key+"/"+tc.modifiers, func(t *testing.T) {
256			// Wayland-format keymaps pinned in testdata/keymaps were produced by
257			// `xkbcli compile-keymap --output-format 1` (libxkbcommon 1.13.2), so
258			// the FD path is always exercised without optional tools.
259			text, e := os.ReadFile("testdata/keymaps/" + strings.ReplaceAll(tc.layout, ",", "_") + ".xkb")
260			if e != nil {
261				t.Fatal(e)
262			}
263			f := keymapFile(t, append(text, 0))
264			k, e := c.NewKeymapFD(int(f.Fd()), len(text)+1)
265			if e != nil {
266				t.Fatal(e)
267			}
268			t.Cleanup(func() { k.Close() })
269			s := state(t, k)
270			var mask uint32
271			if tc.modifiers != "" {
272				idx, e := k.ModIndex(tc.modifiers)
273				if e != nil || idx >= 32 {
274					t.Fatalf("modifier %s: %d %v", tc.modifiers, idx, e)
275				}
276				mask = 1 << idx
277			}
278			var depressed, locked uint32
279			if tc.modifiers == "Lock" {
280				locked = mask
281			} else {
282				depressed = mask
283			}
284			if _, e := s.UpdateMask(depressed, 0, locked, 0, 0, tc.group); e != nil {
285				t.Fatal(e)
286			}
287			if group, e := s.Layout(); e != nil || group != tc.group {
288				t.Fatalf("group %d: %v", group, e)
289			}
290			code := WaylandKeycode(tc.evdev)
291			if sym, e := s.KeySym(code); e != nil || sym != tc.sym {
292				t.Fatalf("keysym %#x want %#x: %v", sym, tc.sym, e)
293			}
294			if v, e := s.UTF8(code); e != nil || v != tc.utf8 {
295				t.Fatalf("utf8 %q want %q: %v", v, tc.utf8, e)
296			}
297			if tc.sym == raw.XKB_KEY_dead_circumflex {
298				if v, e := s.UTF8(WaylandKeycode(18)); e != nil || v != "e" {
299					t.Fatalf("compose input e: %q %v", v, e)
300				}
301				table, e := c.NewComposeTable("en_US.UTF-8")
302				if e != nil {
303					t.Fatal(e)
304				}
305				t.Cleanup(func() { table.Close() })
306				compose, e := table.NewState()
307				if e != nil {
308					t.Fatal(e)
309				}
310				t.Cleanup(func() { compose.Close() })
311				if _, e := compose.Feed(tc.sym); e != nil {
312					t.Fatal(e)
313				}
314				if _, e := compose.Feed(raw.XKB_KEY_e); e != nil {
315					t.Fatal(e)
316				}
317				if status, e := compose.Status(); e != nil || status != raw.XKB_COMPOSE_COMPOSED {
318					t.Fatalf("compose status %d: %v", status, e)
319				}
320				if sym, e := compose.KeySym(); e != nil || sym != raw.XKB_KEY_ecircumflex {
321					t.Fatalf("compose sym %#x: %v", sym, e)
322				}
323				if v, e := compose.UTF8(); e != nil || v != "ê" {
324					t.Fatalf("compose utf8 %q: %v", v, e)
325				}
326			}
327			if _, e := exec.LookPath("xkbcli"); e == nil && tc.group == 0 && tc.modifiers != "Lock" {
328				args := []string{"how-to-type", "--layout", tc.layout}
329				if tc.sym == raw.XKB_KEY_dead_circumflex {
330					args = append(args, "--keysym")
331				}
332				out, e := exec.Command("xkbcli", append(args, tc.key)...).Output()
333				if e != nil {
334					t.Fatal(e)
335				}
336				// The CLI reports XKB keycodes, not evdev codes.
337				pattern := regexp.MustCompile(`(?m)^\s*` + fmt.Sprint(code) + `\s+\S+\s+\d+\s+.*\[.*\]`)
338				if !pattern.Match(out) {
339					t.Fatalf("xkbcli how-to-type lacks keycode %d: %s", code, out)
340				}
341			}
342		})
343	}
344}
345
346func TestCompose(t *testing.T) {
347	c := live(t)
348	table, e := c.NewComposeTable("en_US.UTF-8")
349	if e != nil {
350		t.Fatal(e)
351	}
352	s, e := table.NewState()
353	if e != nil {
354		t.Fatal(e)
355	}
356	table.Close()
357	c.Close()
358	if _, e = s.Feed(raw.XKB_KEY_dead_acute); e != nil {
359		t.Fatal(e)
360	}
361	status, _ := s.Status()
362	if status != raw.XKB_COMPOSE_COMPOSING {
363		t.Fatalf("status %d", status)
364	}
365	s.Feed(raw.XKB_KEY_e)
366	status, _ = s.Status()
367	if status != raw.XKB_COMPOSE_COMPOSED {
368		t.Fatalf("status %d", status)
369	}
370	v, e := s.UTF8()
371	if e != nil || v != "é" {
372		t.Fatalf("compose %q %v", v, e)
373	}
374	s.Reset()
375	s.Feed(raw.XKB_KEY_Multi_key)
376	s.Feed(raw.XKB_KEY_apostrophe)
377	s.Feed(raw.XKB_KEY_e)
378	v, _ = s.UTF8()
379	if v != "é" {
380		t.Fatalf("multikey %q", v)
381	}
382	s.Close()
383	s.Close()
384	table.Close()
385	if _, e = s.UTF8(); !errors.Is(e, ErrClosed) {
386		t.Fatal(e)
387	}
388}
389func TestSizing(t *testing.T) {
390	v, e := sizedUTF8(func(p, n uintptr) int32 { return 0 })
391	if e != nil || v != "" {
392		t.Fatalf("%q %v", v, e)
393	}
394	_, e = sizedUTF8(func(p, n uintptr) int32 { return -1 })
395	if e == nil {
396		t.Fatal("negative result accepted")
397	}
398	v, e = KeysymName(raw.XKB_KEY_EuroSign)
399	if e != nil || v != "EuroSign" {
400		t.Fatalf("name %q %v", v, e)
401	}
402}

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 2

↑↓ moveenter openesc close