detached cb9e8ca

merge: libxkbcommon keymap via purego

commit
cb9e8ca772db42ff6a7adff64e90bcc961d7b60e
author
brice · · 2026-09-23 15:42 UTC
merge of
80c9df5 + 4769550 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +255 -0
A internal/adapters/xkb/xkb.go +201 -0
@@ -0,0 +1,201 @@
1+// Package xkb provides a cgo-free libxkbcommon keymap and keyboard state.
2+package xkb
3+
4+import (
5+ "fmt"
6+ "runtime"
7+ "unsafe"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+ "github.com/bnema/purego"
11+)
12+
13+const (
14+ modsDepressed = 1 << iota
15+ modsLatched
16+ modsLocked
17+ modsEffective
18+ layoutEffective = 1 << 7
19+)
20+
21+type RMLVO struct{ Rules, Model, Layout, Variant, Options string }
22+
23+// Keymap owns its native context, keymap and state. It belongs to one input goroutine.
24+type Keymap struct {
25+ ctx, mapPtr, state uintptr
26+ lib, libc uintptr
27+ contextUnref func(uintptr)
28+ keymapUnref func(uintptr)
29+ stateUnref func(uintptr)
30+ keymapString func(uintptr, uint32) unsafe.Pointer
31+ free func(uintptr)
32+ getSym func(uintptr, uint32) uint32
33+ symName func(uint32, uintptr, uintptr) int32
34+ updateKey func(uintptr, uint32, uint32) uint32
35+ serializeMods func(uintptr, uint32) uint32
36+ serializeLayout func(uintptr, uint32) uint32
37+ modActive func(uintptr, uintptr, uint32) int32
38+ minCode func(uintptr) uint32
39+ maxCode func(uintptr) uint32
40+ symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
41+ fromName func(uintptr, uint32) uint32
42+}
43+
44+type ruleNames struct{ rules, model, layout, variant, options uintptr }
45+
46+func New(names RMLVO) (_ *Keymap, err error) {
47+ k := &Keymap{}
48+ k.lib, err = purego.Dlopen("libxkbcommon.so.0", purego.RTLD_NOW|purego.RTLD_LOCAL)
49+ if err != nil {
50+ return nil, err
51+ }
52+ defer func() {
53+ if err != nil {
54+ k.Close()
55+ }
56+ }()
57+ k.libc, err = purego.Dlopen("libc.so.6", purego.RTLD_NOW|purego.RTLD_LOCAL)
58+ if err != nil {
59+ return nil, err
60+ }
61+ var contextNew func(uint32) uintptr
62+ var keymapNew func(uintptr, *ruleNames, uint32) uintptr
63+ var stateNew func(uintptr) uintptr
64+ bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) }
65+ bind(&contextNew, "context_new")
66+ bind(&k.contextUnref, "context_unref")
67+ bind(&keymapNew, "keymap_new_from_names")
68+ bind(&k.keymapUnref, "keymap_unref")
69+ bind(&stateNew, "state_new")
70+ bind(&k.stateUnref, "state_unref")
71+ bind(&k.keymapString, "keymap_get_as_string")
72+ bind(&k.getSym, "state_key_get_one_sym")
73+ bind(&k.symName, "keysym_get_name")
74+ bind(&k.updateKey, "state_update_key")
75+ bind(&k.serializeMods, "state_serialize_mods")
76+ bind(&k.serializeLayout, "state_serialize_layout")
77+ bind(&k.modActive, "state_mod_name_is_active")
78+ bind(&k.minCode, "keymap_min_keycode")
79+ bind(&k.maxCode, "keymap_max_keycode")
80+ bind(&k.symsLevel, "keymap_key_get_syms_by_level")
81+ bind(&k.fromName, "keysym_from_name")
82+ purego.RegisterLibFunc(&k.free, k.libc, "free")
83+ k.ctx = contextNew(0)
84+ if k.ctx == 0 {
85+ return nil, fmt.Errorf("xkb_context_new failed")
86+ }
87+ values := [...]string{names.Rules, names.Model, names.Layout, names.Variant, names.Options}
88+ var buffers [5][]byte
89+ var pins runtime.Pinner
90+ defer pins.Unpin()
91+ var pointers [5]uintptr
92+ for i, v := range values {
93+ if v == "" {
94+ continue
95+ }
96+ buffers[i] = append([]byte(v), 0)
97+ pins.Pin(&buffers[i][0])
98+ pointers[i] = uintptr(unsafe.Pointer(&buffers[i][0]))
99+ }
100+ r := ruleNames{pointers[0], pointers[1], pointers[2], pointers[3], pointers[4]}
101+ pins.Pin(&r)
102+ k.mapPtr = keymapNew(k.ctx, &r, 0)
103+ runtime.KeepAlive(buffers)
104+ if k.mapPtr == 0 {
105+ return nil, fmt.Errorf("xkb_keymap_new_from_names failed")
106+ }
107+ k.state = stateNew(k.mapPtr)
108+ if k.state == 0 {
109+ return nil, fmt.Errorf("xkb_state_new failed")
110+ }
111+ return k, nil
112+}
113+
114+func (k *Keymap) String() string {
115+ ptr := k.keymapString(k.mapPtr, 1)
116+ if ptr == nil {
117+ return ""
118+ }
119+ defer k.free(uintptr(ptr))
120+ n := 0
121+ for *(*byte)(unsafe.Add(ptr, n)) != 0 {
122+ n++
123+ }
124+ return string(unsafe.Slice((*byte)(ptr), n))
125+}
126+
127+func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyEvent {
128+ code := evdevCode + 8
129+ sym := k.getSym(k.state, code)
130+ var buf [64]byte
131+ n := k.symName(sym, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
132+ name := ""
133+ if n > 0 && n < int32(len(buf)) {
134+ name = string(buf[:n])
135+ }
136+ direction := uint32(0)
137+ if pressed {
138+ direction = 1
139+ }
140+ k.updateKey(k.state, code, direction)
141+ event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode}
142+ event.State = ports.ModState{
143+ Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched),
144+ Locked: k.serializeMods(k.state, modsLocked), Group: k.serializeLayout(k.state, layoutEffective),
145+ }
146+ for _, mod := range []struct {
147+ name string
148+ flag ports.Mods
149+ }{{"Shift", ports.ModShift}, {"Control", ports.ModCtrl}, {"Mod1", ports.ModAlt}, {"Mod4", ports.ModSuper}} {
150+ b := append([]byte(mod.name), 0)
151+ if k.modActive(k.state, uintptr(unsafe.Pointer(&b[0])), modsEffective) > 0 {
152+ event.Mods |= mod.flag
153+ }
154+ runtime.KeepAlive(b)
155+ }
156+ return event
157+}
158+
159+func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {
160+ b := append([]byte(keysymName), 0)
161+ sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)
162+ runtime.KeepAlive(b)
163+ if sym == 0 {
164+ return 0, false, false
165+ }
166+ for code := k.minCode(k.mapPtr); code <= k.maxCode(k.mapPtr); code++ {
167+ for level := uint32(0); level < 2; level++ {
168+ var ptr unsafe.Pointer
169+ count := k.symsLevel(k.mapPtr, code, 0, level, &ptr)
170+ for i := int32(0); i < count; i++ {
171+ if *(*uint32)(unsafe.Add(ptr, uintptr(i)*4)) == sym && code >= 8 {
172+ return code - 8, level == 1, true
173+ }
174+ }
175+ }
176+ }
177+ return 0, false, false
178+}
179+
180+func (k *Keymap) Close() {
181+ if k.state != 0 {
182+ k.stateUnref(k.state)
183+ k.state = 0
184+ }
185+ if k.mapPtr != 0 {
186+ k.keymapUnref(k.mapPtr)
187+ k.mapPtr = 0
188+ }
189+ if k.ctx != 0 {
190+ k.contextUnref(k.ctx)
191+ k.ctx = 0
192+ }
193+ if k.libc != 0 {
194+ _ = purego.Dlclose(k.libc)
195+ k.libc = 0
196+ }
197+ if k.lib != 0 {
198+ _ = purego.Dlclose(k.lib)
199+ k.lib = 0
200+ }
201+}
@@ -0,0 +1,201 @@
1+// Package xkb provides a cgo-free libxkbcommon keymap and keyboard state.
2+package xkb
3+
4+import (
5+ "fmt"
6+ "runtime"
7+ "unsafe"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+ "github.com/bnema/purego"
11+)
12+
13+const (
14+ modsDepressed = 1 << iota
15+ modsLatched
16+ modsLocked
17+ modsEffective
18+ layoutEffective = 1 << 7
19+)
20+
21+type RMLVO struct{ Rules, Model, Layout, Variant, Options string }
22+
23+// Keymap owns its native context, keymap and state. It belongs to one input goroutine.
24+type Keymap struct {
25+ ctx, mapPtr, state uintptr
26+ lib, libc uintptr
27+ contextUnref func(uintptr)
28+ keymapUnref func(uintptr)
29+ stateUnref func(uintptr)
30+ keymapString func(uintptr, uint32) unsafe.Pointer
31+ free func(uintptr)
32+ getSym func(uintptr, uint32) uint32
33+ symName func(uint32, uintptr, uintptr) int32
34+ updateKey func(uintptr, uint32, uint32) uint32
35+ serializeMods func(uintptr, uint32) uint32
36+ serializeLayout func(uintptr, uint32) uint32
37+ modActive func(uintptr, uintptr, uint32) int32
38+ minCode func(uintptr) uint32
39+ maxCode func(uintptr) uint32
40+ symsLevel func(uintptr, uint32, uint32, uint32, *unsafe.Pointer) int32
41+ fromName func(uintptr, uint32) uint32
42+}
43+
44+type ruleNames struct{ rules, model, layout, variant, options uintptr }
45+
46+func New(names RMLVO) (_ *Keymap, err error) {
47+ k := &Keymap{}
48+ k.lib, err = purego.Dlopen("libxkbcommon.so.0", purego.RTLD_NOW|purego.RTLD_LOCAL)
49+ if err != nil {
50+ return nil, err
51+ }
52+ defer func() {
53+ if err != nil {
54+ k.Close()
55+ }
56+ }()
57+ k.libc, err = purego.Dlopen("libc.so.6", purego.RTLD_NOW|purego.RTLD_LOCAL)
58+ if err != nil {
59+ return nil, err
60+ }
61+ var contextNew func(uint32) uintptr
62+ var keymapNew func(uintptr, *ruleNames, uint32) uintptr
63+ var stateNew func(uintptr) uintptr
64+ bind := func(fn any, name string) { purego.RegisterLibFunc(fn, k.lib, "xkb_"+name) }
65+ bind(&contextNew, "context_new")
66+ bind(&k.contextUnref, "context_unref")
67+ bind(&keymapNew, "keymap_new_from_names")
68+ bind(&k.keymapUnref, "keymap_unref")
69+ bind(&stateNew, "state_new")
70+ bind(&k.stateUnref, "state_unref")
71+ bind(&k.keymapString, "keymap_get_as_string")
72+ bind(&k.getSym, "state_key_get_one_sym")
73+ bind(&k.symName, "keysym_get_name")
74+ bind(&k.updateKey, "state_update_key")
75+ bind(&k.serializeMods, "state_serialize_mods")
76+ bind(&k.serializeLayout, "state_serialize_layout")
77+ bind(&k.modActive, "state_mod_name_is_active")
78+ bind(&k.minCode, "keymap_min_keycode")
79+ bind(&k.maxCode, "keymap_max_keycode")
80+ bind(&k.symsLevel, "keymap_key_get_syms_by_level")
81+ bind(&k.fromName, "keysym_from_name")
82+ purego.RegisterLibFunc(&k.free, k.libc, "free")
83+ k.ctx = contextNew(0)
84+ if k.ctx == 0 {
85+ return nil, fmt.Errorf("xkb_context_new failed")
86+ }
87+ values := [...]string{names.Rules, names.Model, names.Layout, names.Variant, names.Options}
88+ var buffers [5][]byte
89+ var pins runtime.Pinner
90+ defer pins.Unpin()
91+ var pointers [5]uintptr
92+ for i, v := range values {
93+ if v == "" {
94+ continue
95+ }
96+ buffers[i] = append([]byte(v), 0)
97+ pins.Pin(&buffers[i][0])
98+ pointers[i] = uintptr(unsafe.Pointer(&buffers[i][0]))
99+ }
100+ r := ruleNames{pointers[0], pointers[1], pointers[2], pointers[3], pointers[4]}
101+ pins.Pin(&r)
102+ k.mapPtr = keymapNew(k.ctx, &r, 0)
103+ runtime.KeepAlive(buffers)
104+ if k.mapPtr == 0 {
105+ return nil, fmt.Errorf("xkb_keymap_new_from_names failed")
106+ }
107+ k.state = stateNew(k.mapPtr)
108+ if k.state == 0 {
109+ return nil, fmt.Errorf("xkb_state_new failed")
110+ }
111+ return k, nil
112+}
113+
114+func (k *Keymap) String() string {
115+ ptr := k.keymapString(k.mapPtr, 1)
116+ if ptr == nil {
117+ return ""
118+ }
119+ defer k.free(uintptr(ptr))
120+ n := 0
121+ for *(*byte)(unsafe.Add(ptr, n)) != 0 {
122+ n++
123+ }
124+ return string(unsafe.Slice((*byte)(ptr), n))
125+}
126+
127+func (k *Keymap) Key(evdevCode uint32, pressed bool, timeMsec uint32) ports.KeyEvent {
128+ code := evdevCode + 8
129+ sym := k.getSym(k.state, code)
130+ var buf [64]byte
131+ n := k.symName(sym, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
132+ name := ""
133+ if n > 0 && n < int32(len(buf)) {
134+ name = string(buf[:n])
135+ }
136+ direction := uint32(0)
137+ if pressed {
138+ direction = 1
139+ }
140+ k.updateKey(k.state, code, direction)
141+ event := ports.KeyEvent{Keysym: name, Pressed: pressed, TimeMsec: timeMsec, Keycode: evdevCode}
142+ event.State = ports.ModState{
143+ Depressed: k.serializeMods(k.state, modsDepressed), Latched: k.serializeMods(k.state, modsLatched),
144+ Locked: k.serializeMods(k.state, modsLocked), Group: k.serializeLayout(k.state, layoutEffective),
145+ }
146+ for _, mod := range []struct {
147+ name string
148+ flag ports.Mods
149+ }{{"Shift", ports.ModShift}, {"Control", ports.ModCtrl}, {"Mod1", ports.ModAlt}, {"Mod4", ports.ModSuper}} {
150+ b := append([]byte(mod.name), 0)
151+ if k.modActive(k.state, uintptr(unsafe.Pointer(&b[0])), modsEffective) > 0 {
152+ event.Mods |= mod.flag
153+ }
154+ runtime.KeepAlive(b)
155+ }
156+ return event
157+}
158+
159+func (k *Keymap) KeycodeFor(keysymName string) (evdev uint32, shift bool, ok bool) {
160+ b := append([]byte(keysymName), 0)
161+ sym := k.fromName(uintptr(unsafe.Pointer(&b[0])), 0)
162+ runtime.KeepAlive(b)
163+ if sym == 0 {
164+ return 0, false, false
165+ }
166+ for code := k.minCode(k.mapPtr); code <= k.maxCode(k.mapPtr); code++ {
167+ for level := uint32(0); level < 2; level++ {
168+ var ptr unsafe.Pointer
169+ count := k.symsLevel(k.mapPtr, code, 0, level, &ptr)
170+ for i := int32(0); i < count; i++ {
171+ if *(*uint32)(unsafe.Add(ptr, uintptr(i)*4)) == sym && code >= 8 {
172+ return code - 8, level == 1, true
173+ }
174+ }
175+ }
176+ }
177+ return 0, false, false
178+}
179+
180+func (k *Keymap) Close() {
181+ if k.state != 0 {
182+ k.stateUnref(k.state)
183+ k.state = 0
184+ }
185+ if k.mapPtr != 0 {
186+ k.keymapUnref(k.mapPtr)
187+ k.mapPtr = 0
188+ }
189+ if k.ctx != 0 {
190+ k.contextUnref(k.ctx)
191+ k.ctx = 0
192+ }
193+ if k.libc != 0 {
194+ _ = purego.Dlclose(k.libc)
195+ k.libc = 0
196+ }
197+ if k.lib != 0 {
198+ _ = purego.Dlclose(k.lib)
199+ k.lib = 0
200+ }
201+}
A internal/adapters/xkb/xkb_test.go +54 -0
@@ -0,0 +1,54 @@
1+package xkb
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+func newTest(t *testing.T, layout string) *Keymap {
11+ t.Helper()
12+ k, err := New(RMLVO{Layout: layout})
13+ if err != nil {
14+ if strings.Contains(err.Error(), "libxkbcommon.so.0") {
15+ t.Skipf("xkbcommon unavailable: %v", err)
16+ }
17+ t.Fatal(err)
18+ }
19+ t.Cleanup(k.Close)
20+ return k
21+}
22+
23+func TestKeymap(t *testing.T) {
24+ k := newTest(t, "us")
25+ if !strings.Contains(k.String(), "xkb_keymap") {
26+ t.Fatal("missing keymap text")
27+ }
28+ if ev := k.Key(30, true, 123); ev.Keysym != "a" || ev.Keycode != 30 || !ev.Pressed || ev.TimeMsec != 123 {
29+ t.Fatalf("A: %+v", ev)
30+ }
31+ k.Key(30, false, 0)
32+ k.Key(42, true, 0)
33+ if ev := k.Key(30, true, 0); ev.Keysym != "A" || ev.Mods&ports.ModShift == 0 || ev.State.Depressed == 0 {
34+ t.Fatalf("shift A: %+v", ev)
35+ }
36+ k.Key(42, false, 0)
37+ if ev := k.Key(30, false, 0); ev.Mods&ports.ModShift != 0 {
38+ t.Fatalf("shift release: %+v", ev)
39+ }
40+ for _, tt := range []struct {
41+ name string
42+ code uint32
43+ shift, ok bool
44+ }{{"a", 30, false, true}, {"A", 30, true, true}, {"Return", 28, false, true}, {"nope", 0, false, false}} {
45+ code, shift, ok := k.KeycodeFor(tt.name)
46+ if code != tt.code || shift != tt.shift || ok != tt.ok {
47+ t.Errorf("%s: %d %v %v", tt.name, code, shift, ok)
48+ }
49+ }
50+ fr := newTest(t, "fr")
51+ if code, _, ok := fr.KeycodeFor("a"); !ok || code != 16 {
52+ t.Errorf("fr a: %d %v", code, ok)
53+ }
54+}
@@ -0,0 +1,54 @@
1+package xkb
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/nefertty/internal/ports"
8+)
9+
10+func newTest(t *testing.T, layout string) *Keymap {
11+ t.Helper()
12+ k, err := New(RMLVO{Layout: layout})
13+ if err != nil {
14+ if strings.Contains(err.Error(), "libxkbcommon.so.0") {
15+ t.Skipf("xkbcommon unavailable: %v", err)
16+ }
17+ t.Fatal(err)
18+ }
19+ t.Cleanup(k.Close)
20+ return k
21+}
22+
23+func TestKeymap(t *testing.T) {
24+ k := newTest(t, "us")
25+ if !strings.Contains(k.String(), "xkb_keymap") {
26+ t.Fatal("missing keymap text")
27+ }
28+ if ev := k.Key(30, true, 123); ev.Keysym != "a" || ev.Keycode != 30 || !ev.Pressed || ev.TimeMsec != 123 {
29+ t.Fatalf("A: %+v", ev)
30+ }
31+ k.Key(30, false, 0)
32+ k.Key(42, true, 0)
33+ if ev := k.Key(30, true, 0); ev.Keysym != "A" || ev.Mods&ports.ModShift == 0 || ev.State.Depressed == 0 {
34+ t.Fatalf("shift A: %+v", ev)
35+ }
36+ k.Key(42, false, 0)
37+ if ev := k.Key(30, false, 0); ev.Mods&ports.ModShift != 0 {
38+ t.Fatalf("shift release: %+v", ev)
39+ }
40+ for _, tt := range []struct {
41+ name string
42+ code uint32
43+ shift, ok bool
44+ }{{"a", 30, false, true}, {"A", 30, true, true}, {"Return", 28, false, true}, {"nope", 0, false, false}} {
45+ code, shift, ok := k.KeycodeFor(tt.name)
46+ if code != tt.code || shift != tt.shift || ok != tt.ok {
47+ t.Errorf("%s: %d %v %v", tt.name, code, shift, ok)
48+ }
49+ }
50+ fr := newTest(t, "fr")
51+ if code, _, ok := fr.KeycodeFor("a"); !ok || code != 16 {
52+ t.Errorf("fr a: %d %v", code, ok)
53+ }
54+}

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