detached 4e14261

feat(wayland): support zwp_virtual_keyboard_v1 so wtype can type into the focused window

commit
4e14261398ec03fa5ae5f79e924d8ed97e574159
author
brice · · 2026-09-26 09:01 UTC
parent
7e09e66
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +245 -2
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.27
44
55 require (
66 github.com/bnema/purego v0.11.0-bnema.4
7- github.com/bnema/purego-libwayland v0.1.0
7+ github.com/bnema/purego-libwayland v0.1.1-0.20260926085626-ac96fe0bd3c3
88 github.com/bnema/purego-vulkan v0.4.0
99 github.com/bnema/wlturbo v0.2.0
1010 github.com/bnema/zerowrap v1.4.1
@@ -4,7 +4,7 @@ go 1.27
4 4
5 require (5 require (
6 github.com/bnema/purego v0.11.0-bnema.46 github.com/bnema/purego v0.11.0-bnema.4
7- github.com/bnema/purego-libwayland v0.1.07+ github.com/bnema/purego-libwayland v0.1.1-0.20260926085626-ac96fe0bd3c3
8 github.com/bnema/purego-vulkan v0.4.08 github.com/bnema/purego-vulkan v0.4.0
9 github.com/bnema/wlturbo v0.2.09 github.com/bnema/wlturbo v0.2.0
10 github.com/bnema/zerowrap v1.4.110 github.com/bnema/zerowrap v1.4.1
M go.sum +2 -0
@@ -2,6 +2,8 @@ github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3
22 github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg=
33 github.com/bnema/purego-libwayland v0.1.0 h1:bPsZcR47Ra1uBM8h2BGk1MhY2WnTX31hzo7KGhhyA/E=
44 github.com/bnema/purego-libwayland v0.1.0/go.mod h1:nN9HngjyWl/8pHLpFcFgtgQCnae8oP9xXjLfY9hMS8w=
5+github.com/bnema/purego-libwayland v0.1.1-0.20260926085626-ac96fe0bd3c3 h1:Wgfzm6K+cub07b6I+Hr4JPS3Xp97DD2Imq0LVRusvk4=
6+github.com/bnema/purego-libwayland v0.1.1-0.20260926085626-ac96fe0bd3c3/go.mod h1:nN9HngjyWl/8pHLpFcFgtgQCnae8oP9xXjLfY9hMS8w=
57 github.com/bnema/purego-vulkan v0.4.0 h1:AcnZHek4AJ1ECnQ+8azKtbMP/T15NO25G9U14k1nImo=
68 github.com/bnema/purego-vulkan v0.4.0/go.mod h1:ttS5qS7yTInFL8GvZwhvXkEc96u/nwXza0ZAMu9rpYM=
79 github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ=
@@ -2,6 +2,8 @@ github.com/bnema/purego v0.11.0-bnema.4 h1:uQjx9QtM945rD6HkwYzPGuVN7wKr5HrFKIWE3
2 github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg=2 github.com/bnema/purego v0.11.0-bnema.4/go.mod h1:LHW8Sb4QO18dQaLVRU8tZCGM0SjsC+agFW/3M3nCUtg=
3 github.com/bnema/purego-libwayland v0.1.0 h1:bPsZcR47Ra1uBM8h2BGk1MhY2WnTX31hzo7KGhhyA/E=3 github.com/bnema/purego-libwayland v0.1.0 h1:bPsZcR47Ra1uBM8h2BGk1MhY2WnTX31hzo7KGhhyA/E=
4 github.com/bnema/purego-libwayland v0.1.0/go.mod h1:nN9HngjyWl/8pHLpFcFgtgQCnae8oP9xXjLfY9hMS8w=4 github.com/bnema/purego-libwayland v0.1.0/go.mod h1:nN9HngjyWl/8pHLpFcFgtgQCnae8oP9xXjLfY9hMS8w=
5+github.com/bnema/purego-libwayland v0.1.1-0.20260926085626-ac96fe0bd3c3 h1:Wgfzm6K+cub07b6I+Hr4JPS3Xp97DD2Imq0LVRusvk4=
6+github.com/bnema/purego-libwayland v0.1.1-0.20260926085626-ac96fe0bd3c3/go.mod h1:nN9HngjyWl/8pHLpFcFgtgQCnae8oP9xXjLfY9hMS8w=
5 github.com/bnema/purego-vulkan v0.4.0 h1:AcnZHek4AJ1ECnQ+8azKtbMP/T15NO25G9U14k1nImo=7 github.com/bnema/purego-vulkan v0.4.0 h1:AcnZHek4AJ1ECnQ+8azKtbMP/T15NO25G9U14k1nImo=
6 github.com/bnema/purego-vulkan v0.4.0/go.mod h1:ttS5qS7yTInFL8GvZwhvXkEc96u/nwXza0ZAMu9rpYM=8 github.com/bnema/purego-vulkan v0.4.0/go.mod h1:ttS5qS7yTInFL8GvZwhvXkEc96u/nwXza0ZAMu9rpYM=
7 github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ=9 github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ=
M internal/adapters/wayland/globals.go +3 -1
@@ -20,6 +20,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
2020 func() error { return registerTearing(d, s) },
2121 func() error { return registerPresentation(d, s) },
2222 func() error { return registerActivation(d, s) },
23+ func() error { return registerVirtualKeyboard(d, s) },
2324 func() error {
2425 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
2526 },
@@ -393,7 +394,8 @@ func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {
393394 return
394395 }
395396 s.keyboards[r.Client()] = append(s.keyboards[r.Client()], k)
396- k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
397+ fd, size := s.currentKeymap()
398+ k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), fd, size)
397399 if r.Version() >= 4 {
398400 k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
399401 }
@@ -20,6 +20,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
20 func() error { return registerTearing(d, s) },20 func() error { return registerTearing(d, s) },
21 func() error { return registerPresentation(d, s) },21 func() error { return registerPresentation(d, s) },
22 func() error { return registerActivation(d, s) },22 func() error { return registerActivation(d, s) },
23+ func() error { return registerVirtualKeyboard(d, s) },
23 func() error {24 func() error {
24 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })25 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
25 },26 },
@@ -393,7 +394,8 @@ func (h seat) GetKeyboard(r *wayland.Seat, id uint32) {
393 return394 return
394 }395 }
395 s.keyboards[r.Client()] = append(s.keyboards[r.Client()], k)396 s.keyboards[r.Client()] = append(s.keyboards[r.Client()], k)
396- k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)397+ fd, size := s.currentKeymap()
398+ k.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), fd, size)
397 if r.Version() >= 4 {399 if r.Version() >= 4 {
398 k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))400 k.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
399 }401 }
M internal/adapters/wayland/server.go +3 -0
@@ -91,6 +91,7 @@ type Server struct {
9191 heldKeys map[uint32]bool
9292 keymapFD int
9393 keymapSize uint32
94+ keymapOwner *virtualKeyboard // nil: keyboards carry the seat keymap
9495 repeatRate, repeatDelay int
9596 // eventMu protects only the notification queue, not display-owned window state.
9697 // The event queue is unbounded by design: it grows only if core stops draining
@@ -428,6 +429,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
428429 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
429430 return
430431 }
432+ s.useKeymap(nil)
431433 if s.heldKeys == nil {
432434 s.heldKeys = make(map[uint32]bool)
433435 }
@@ -599,6 +601,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
599601 unix.Close(s.keymapFD)
600602 }
601603 s.keymapFD, s.keymapSize = fd, size
604+ s.keymapOwner = nil
602605 focused := s.focused
603606 s.changeFocus(0)
604607 s.heldKeys = nil
@@ -91,6 +91,7 @@ type Server struct {
91 heldKeys map[uint32]bool91 heldKeys map[uint32]bool
92 keymapFD int92 keymapFD int
93 keymapSize uint3293 keymapSize uint32
94+ keymapOwner *virtualKeyboard // nil: keyboards carry the seat keymap
94 repeatRate, repeatDelay int95 repeatRate, repeatDelay int
95 // eventMu protects only the notification queue, not display-owned window state.96 // eventMu protects only the notification queue, not display-owned window state.
96 // The event queue is unbounded by design: it grows only if core stops draining97 // The event queue is unbounded by design: it grows only if core stops draining
@@ -428,6 +429,7 @@ func (s *Server) apply(cmd ports.ClientCommand) {
428 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")429 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
429 return430 return
430 }431 }
432+ s.useKeymap(nil)
431 if s.heldKeys == nil {433 if s.heldKeys == nil {
432 s.heldKeys = make(map[uint32]bool)434 s.heldKeys = make(map[uint32]bool)
433 }435 }
@@ -599,6 +601,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
599 unix.Close(s.keymapFD)601 unix.Close(s.keymapFD)
600 }602 }
601 s.keymapFD, s.keymapSize = fd, size603 s.keymapFD, s.keymapSize = fd, size
604+ s.keymapOwner = nil
602 focused := s.focused605 focused := s.focused
603 s.changeFocus(0)606 s.changeFocus(0)
604 s.heldKeys = nil607 s.heldKeys = nil
A internal/adapters/wayland/virtualkeyboard.go +133 -0
@@ -0,0 +1,133 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/virtualkeyboard"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/server"
7+ "golang.org/x/sys/unix"
8+)
9+
10+// virtualKeyboard is a zwp_virtual_keyboard_v1 (wtype, dictation tools).
11+// Its keys go to the focused client. The focused client's keyboards carry
12+// the keymap of whichever keyboard typed last: a virtual key switches them
13+// to the virtual keymap, the next real key switches them back.
14+type virtualKeyboard struct {
15+ server *Server
16+ fd int
17+ size uint32
18+}
19+
20+func registerVirtualKeyboard(d *server.Display, s *Server) error {
21+ return virtualkeyboard.NewZwpVirtualKeyboardManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
22+ _, _ = virtualkeyboard.NewZwpVirtualKeyboardManagerV1(c, int32(v), id, virtualKeyboardManager{s})
23+ })
24+}
25+
26+type virtualKeyboardManager struct{ server *Server }
27+
28+func (m virtualKeyboardManager) CreateVirtualKeyboard(r *virtualkeyboard.ZwpVirtualKeyboardManagerV1, _ *wayland.Seat, id uint32) {
29+ k := &virtualKeyboard{server: m.server, fd: -1}
30+ res, err := virtualkeyboard.NewZwpVirtualKeyboardV1(r.Client(), r.Version(), id, k)
31+ if err != nil {
32+ return
33+ }
34+ res.OnDestroy = k.release
35+ m.server.log.Info().Int("pid", r.Client().PID()).Msg("virtual keyboard created")
36+}
37+
38+func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format uint32, fd int, size uint32) {
39+ if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || size == 0 {
40+ unix.Close(fd)
41+ return
42+ }
43+ if k.fd >= 0 {
44+ unix.Close(k.fd)
45+ }
46+ k.fd, k.size = fd, size
47+ if s := k.server; s.keymapOwner == k {
48+ // Clients must see the new layout before the next key.
49+ s.sendKeymapAll(fd, size)
50+ }
51+}
52+
53+func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key, state uint32) {
54+ if k.fd < 0 {
55+ r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "key before keymap")
56+ return
57+ }
58+ s := k.server
59+ _, keyboards := s.focusTarget(s.focused)
60+ if len(keyboards) == 0 {
61+ return
62+ }
63+ s.useKeymap(k)
64+ s.serial++
65+ for _, kb := range keyboards {
66+ kb.SendKey(s.serial, time, key, state)
67+ }
68+}
69+
70+func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) {
71+ if k.fd < 0 {
72+ r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "modifiers before keymap")
73+ return
74+ }
75+ s := k.server
76+ _, keyboards := s.focusTarget(s.focused)
77+ if len(keyboards) == 0 {
78+ return
79+ }
80+ s.useKeymap(k)
81+ s.serial++
82+ for _, kb := range keyboards {
83+ kb.SendModifiers(s.serial, depressed, latched, locked, group)
84+ }
85+}
86+
87+func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {}
88+
89+// release runs when the resource dies, by request or client disconnect.
90+func (k *virtualKeyboard) release() {
91+ if s := k.server; s.keymapOwner == k {
92+ s.useKeymap(nil)
93+ }
94+ if k.fd >= 0 {
95+ unix.Close(k.fd)
96+ k.fd = -1
97+ }
98+}
99+
100+// useKeymap makes every keyboard carry the keymap of k, or the seat keymap
101+// when k is nil. Switching back restores the seat modifiers too.
102+func (s *Server) useKeymap(k *virtualKeyboard) {
103+ if s.keymapOwner == k {
104+ return
105+ }
106+ s.keymapOwner = k
107+ s.sendKeymapAll(s.currentKeymap())
108+ if k == nil {
109+ s.serial++
110+ for _, kb := range s.clientKeyboards(s.focusClient()) {
111+ s.sendModifiers(kb)
112+ }
113+ }
114+ s.log.Debug().Bool("virtual", k != nil).Msg("keymap switched")
115+}
116+
117+func (s *Server) sendKeymapAll(fd int, size uint32) {
118+ for _, list := range s.keyboards {
119+ for _, kb := range list {
120+ if kb.Resource.Alive() {
121+ kb.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), fd, size)
122+ }
123+ }
124+ }
125+}
126+
127+// currentKeymap is the keymap a new keyboard must start with.
128+func (s *Server) currentKeymap() (int, uint32) {
129+ if k := s.keymapOwner; k != nil {
130+ return k.fd, k.size
131+ }
132+ return s.keymapFD, s.keymapSize
133+}
@@ -0,0 +1,133 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/purego-libwayland/protocol/virtualkeyboard"
5+ "github.com/bnema/purego-libwayland/protocol/wayland"
6+ "github.com/bnema/purego-libwayland/server"
7+ "golang.org/x/sys/unix"
8+)
9+
10+// virtualKeyboard is a zwp_virtual_keyboard_v1 (wtype, dictation tools).
11+// Its keys go to the focused client. The focused client's keyboards carry
12+// the keymap of whichever keyboard typed last: a virtual key switches them
13+// to the virtual keymap, the next real key switches them back.
14+type virtualKeyboard struct {
15+ server *Server
16+ fd int
17+ size uint32
18+}
19+
20+func registerVirtualKeyboard(d *server.Display, s *Server) error {
21+ return virtualkeyboard.NewZwpVirtualKeyboardManagerV1Global(d, 1, func(c server.Client, v, id uint32) {
22+ _, _ = virtualkeyboard.NewZwpVirtualKeyboardManagerV1(c, int32(v), id, virtualKeyboardManager{s})
23+ })
24+}
25+
26+type virtualKeyboardManager struct{ server *Server }
27+
28+func (m virtualKeyboardManager) CreateVirtualKeyboard(r *virtualkeyboard.ZwpVirtualKeyboardManagerV1, _ *wayland.Seat, id uint32) {
29+ k := &virtualKeyboard{server: m.server, fd: -1}
30+ res, err := virtualkeyboard.NewZwpVirtualKeyboardV1(r.Client(), r.Version(), id, k)
31+ if err != nil {
32+ return
33+ }
34+ res.OnDestroy = k.release
35+ m.server.log.Info().Int("pid", r.Client().PID()).Msg("virtual keyboard created")
36+}
37+
38+func (k *virtualKeyboard) Keymap(_ *virtualkeyboard.ZwpVirtualKeyboardV1, format uint32, fd int, size uint32) {
39+ if format != uint32(wayland.KeyboardKeymapFormatXkbV1) || size == 0 {
40+ unix.Close(fd)
41+ return
42+ }
43+ if k.fd >= 0 {
44+ unix.Close(k.fd)
45+ }
46+ k.fd, k.size = fd, size
47+ if s := k.server; s.keymapOwner == k {
48+ // Clients must see the new layout before the next key.
49+ s.sendKeymapAll(fd, size)
50+ }
51+}
52+
53+func (k *virtualKeyboard) Key(r *virtualkeyboard.ZwpVirtualKeyboardV1, time, key, state uint32) {
54+ if k.fd < 0 {
55+ r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "key before keymap")
56+ return
57+ }
58+ s := k.server
59+ _, keyboards := s.focusTarget(s.focused)
60+ if len(keyboards) == 0 {
61+ return
62+ }
63+ s.useKeymap(k)
64+ s.serial++
65+ for _, kb := range keyboards {
66+ kb.SendKey(s.serial, time, key, state)
67+ }
68+}
69+
70+func (k *virtualKeyboard) Modifiers(r *virtualkeyboard.ZwpVirtualKeyboardV1, depressed, latched, locked, group uint32) {
71+ if k.fd < 0 {
72+ r.PostError(uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap), "modifiers before keymap")
73+ return
74+ }
75+ s := k.server
76+ _, keyboards := s.focusTarget(s.focused)
77+ if len(keyboards) == 0 {
78+ return
79+ }
80+ s.useKeymap(k)
81+ s.serial++
82+ for _, kb := range keyboards {
83+ kb.SendModifiers(s.serial, depressed, latched, locked, group)
84+ }
85+}
86+
87+func (*virtualKeyboard) Destroy(*virtualkeyboard.ZwpVirtualKeyboardV1) {}
88+
89+// release runs when the resource dies, by request or client disconnect.
90+func (k *virtualKeyboard) release() {
91+ if s := k.server; s.keymapOwner == k {
92+ s.useKeymap(nil)
93+ }
94+ if k.fd >= 0 {
95+ unix.Close(k.fd)
96+ k.fd = -1
97+ }
98+}
99+
100+// useKeymap makes every keyboard carry the keymap of k, or the seat keymap
101+// when k is nil. Switching back restores the seat modifiers too.
102+func (s *Server) useKeymap(k *virtualKeyboard) {
103+ if s.keymapOwner == k {
104+ return
105+ }
106+ s.keymapOwner = k
107+ s.sendKeymapAll(s.currentKeymap())
108+ if k == nil {
109+ s.serial++
110+ for _, kb := range s.clientKeyboards(s.focusClient()) {
111+ s.sendModifiers(kb)
112+ }
113+ }
114+ s.log.Debug().Bool("virtual", k != nil).Msg("keymap switched")
115+}
116+
117+func (s *Server) sendKeymapAll(fd int, size uint32) {
118+ for _, list := range s.keyboards {
119+ for _, kb := range list {
120+ if kb.Resource.Alive() {
121+ kb.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), fd, size)
122+ }
123+ }
124+ }
125+}
126+
127+// currentKeymap is the keymap a new keyboard must start with.
128+func (s *Server) currentKeymap() (int, uint32) {
129+ if k := s.keymapOwner; k != nil {
130+ return k.fd, k.size
131+ }
132+ return s.keymapFD, s.keymapSize
133+}
A internal/adapters/wayland/virtualkeyboard_test.go +103 -0
@@ -0,0 +1,103 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+ "github.com/bnema/purego-libwayland/protocol/virtualkeyboard"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "github.com/bnema/wlturbo"
13+ "golang.org/x/sys/unix"
14+)
15+
16+// virtualTyper binds a virtual keyboard on its own connection, as wtype does.
17+func virtualTyper(t *testing.T, s *Server, dir string) (*wlturbo.Display, uint32) {
18+ t.Helper()
19+ c := protocolClient(t, s, dir)
20+ seat := bindProtocol(t, c, "wl_seat")
21+ registerProtocol(t, c, seat)
22+ manager := bindProtocol(t, c, "zwp_virtual_keyboard_manager_v1")
23+ registerProtocol(t, c, manager)
24+ kb := c.AllocateID()
25+ registerProtocol(t, c, kb)
26+ requestProtocol(t, c, manager, virtualkeyboard.ZwpVirtualKeyboardManagerV1RequestCreateVirtualKeyboard, seat, kb)
27+ return c, kb
28+}
29+
30+func sendVirtualKeymap(t *testing.T, c *wlturbo.Display, kb uint32, keymap string) {
31+ t.Helper()
32+ fd, size, err := keymapFile(keymap)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer unix.Close(fd)
37+ if err := c.SendRequestWithFDs(kb, uint16(virtualkeyboard.ZwpVirtualKeyboardV1RequestKeymap), []int{fd}, uint32(wayland.KeyboardKeymapFormatXkbV1), size); err != nil {
38+ t.Fatal(err)
39+ }
40+}
41+
42+// Virtual keys reach the focused client under the virtual keymap; the next
43+// real key switches it back to the seat keymap.
44+func TestVirtualKeyboardTypesIntoFocus(t *testing.T) {
45+ s, events, commands, dir := keyboardServer(t)
46+ target := protocolClient(t, s, dir)
47+ seat := bindProtocol(t, target, "wl_seat")
48+ registerProtocol(t, target, seat)
49+ proxy := &eventsProxy{}
50+ proxy.SetID(target.AllocateID())
51+ target.Context().Register(proxy)
52+ requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())
53+ w := toplevelMapper(t, target, events)()
54+ commands <- ports.FocusWindow{ID: w.ID}
55+ waitFocus(t, s, w.ID)
56+ if err := target.Roundtrip(); err != nil {
57+ t.Fatal(err)
58+ }
59+ proxy.opcodes, proxy.keymaps = nil, nil
60+
61+ typer, kb := virtualTyper(t, s, dir)
62+ virtual := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
63+ sendVirtualKeymap(t, typer, kb, virtual)
64+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
65+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(30), uint32(0))
66+ if err := typer.Roundtrip(); err != nil {
67+ t.Fatal(err)
68+ }
69+ if err := target.Roundtrip(); err != nil {
70+ t.Fatal(err)
71+ }
72+ want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventKey), uint16(wayland.KeyboardEventKey)}
73+ if !slices.Equal(proxy.opcodes, want) {
74+ t.Fatalf("virtual events %v, want %v", proxy.opcodes, want)
75+ }
76+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != virtual {
77+ t.Fatal("focused client did not get the virtual keymap")
78+ }
79+
80+ proxy.opcodes, proxy.keymaps = nil, nil
81+ commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 3}}
82+ deadline := time.Now().Add(2 * time.Second)
83+ for !slices.Contains(proxy.opcodes, uint16(wayland.KeyboardEventKey)) && time.Now().Before(deadline) {
84+ if err := target.Roundtrip(); err != nil {
85+ t.Fatal(err)
86+ }
87+ time.Sleep(5 * time.Millisecond)
88+ }
89+ want = []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventModifiers), uint16(wayland.KeyboardEventKey)}
90+ if !slices.Equal(proxy.opcodes, want) {
91+ t.Fatalf("real key events %v, want %v", proxy.opcodes, want)
92+ }
93+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != keymapText(t) {
94+ t.Fatal("seat keymap not restored")
95+ }
96+}
97+
98+func TestVirtualKeyBeforeKeymap(t *testing.T) {
99+ s, _, _, dir := keyboardServer(t)
100+ typer, kb := virtualTyper(t, s, dir)
101+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
102+ expectProtocolError(t, typer, kb, uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap))
103+}
@@ -0,0 +1,103 @@
1+package wayland
2+
3+import (
4+ "slices"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/nefertty/internal/ports"
10+ "github.com/bnema/purego-libwayland/protocol/virtualkeyboard"
11+ "github.com/bnema/purego-libwayland/protocol/wayland"
12+ "github.com/bnema/wlturbo"
13+ "golang.org/x/sys/unix"
14+)
15+
16+// virtualTyper binds a virtual keyboard on its own connection, as wtype does.
17+func virtualTyper(t *testing.T, s *Server, dir string) (*wlturbo.Display, uint32) {
18+ t.Helper()
19+ c := protocolClient(t, s, dir)
20+ seat := bindProtocol(t, c, "wl_seat")
21+ registerProtocol(t, c, seat)
22+ manager := bindProtocol(t, c, "zwp_virtual_keyboard_manager_v1")
23+ registerProtocol(t, c, manager)
24+ kb := c.AllocateID()
25+ registerProtocol(t, c, kb)
26+ requestProtocol(t, c, manager, virtualkeyboard.ZwpVirtualKeyboardManagerV1RequestCreateVirtualKeyboard, seat, kb)
27+ return c, kb
28+}
29+
30+func sendVirtualKeymap(t *testing.T, c *wlturbo.Display, kb uint32, keymap string) {
31+ t.Helper()
32+ fd, size, err := keymapFile(keymap)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ defer unix.Close(fd)
37+ if err := c.SendRequestWithFDs(kb, uint16(virtualkeyboard.ZwpVirtualKeyboardV1RequestKeymap), []int{fd}, uint32(wayland.KeyboardKeymapFormatXkbV1), size); err != nil {
38+ t.Fatal(err)
39+ }
40+}
41+
42+// Virtual keys reach the focused client under the virtual keymap; the next
43+// real key switches it back to the seat keymap.
44+func TestVirtualKeyboardTypesIntoFocus(t *testing.T) {
45+ s, events, commands, dir := keyboardServer(t)
46+ target := protocolClient(t, s, dir)
47+ seat := bindProtocol(t, target, "wl_seat")
48+ registerProtocol(t, target, seat)
49+ proxy := &eventsProxy{}
50+ proxy.SetID(target.AllocateID())
51+ target.Context().Register(proxy)
52+ requestProtocol(t, target, seat, wayland.SeatRequestGetKeyboard, proxy.ID())
53+ w := toplevelMapper(t, target, events)()
54+ commands <- ports.FocusWindow{ID: w.ID}
55+ waitFocus(t, s, w.ID)
56+ if err := target.Roundtrip(); err != nil {
57+ t.Fatal(err)
58+ }
59+ proxy.opcodes, proxy.keymaps = nil, nil
60+
61+ typer, kb := virtualTyper(t, s, dir)
62+ virtual := strings.Replace(keymapText(t), "xkb_keymap", "xkb_keymap ", 1)
63+ sendVirtualKeymap(t, typer, kb, virtual)
64+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
65+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(2), uint32(30), uint32(0))
66+ if err := typer.Roundtrip(); err != nil {
67+ t.Fatal(err)
68+ }
69+ if err := target.Roundtrip(); err != nil {
70+ t.Fatal(err)
71+ }
72+ want := []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventKey), uint16(wayland.KeyboardEventKey)}
73+ if !slices.Equal(proxy.opcodes, want) {
74+ t.Fatalf("virtual events %v, want %v", proxy.opcodes, want)
75+ }
76+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != virtual {
77+ t.Fatal("focused client did not get the virtual keymap")
78+ }
79+
80+ proxy.opcodes, proxy.keymaps = nil, nil
81+ commands <- ports.ForwardKey{ID: w.ID, Key: ports.KeyEvent{Keycode: 30, Pressed: true, TimeMsec: 3}}
82+ deadline := time.Now().Add(2 * time.Second)
83+ for !slices.Contains(proxy.opcodes, uint16(wayland.KeyboardEventKey)) && time.Now().Before(deadline) {
84+ if err := target.Roundtrip(); err != nil {
85+ t.Fatal(err)
86+ }
87+ time.Sleep(5 * time.Millisecond)
88+ }
89+ want = []uint16{uint16(wayland.KeyboardEventKeymap), uint16(wayland.KeyboardEventModifiers), uint16(wayland.KeyboardEventKey)}
90+ if !slices.Equal(proxy.opcodes, want) {
91+ t.Fatalf("real key events %v, want %v", proxy.opcodes, want)
92+ }
93+ if len(proxy.keymaps) != 1 || proxy.keymaps[0] != keymapText(t) {
94+ t.Fatal("seat keymap not restored")
95+ }
96+}
97+
98+func TestVirtualKeyBeforeKeymap(t *testing.T) {
99+ s, _, _, dir := keyboardServer(t)
100+ typer, kb := virtualTyper(t, s, dir)
101+ requestProtocol(t, typer, kb, virtualkeyboard.ZwpVirtualKeyboardV1RequestKey, uint32(1), uint32(30), uint32(1))
102+ expectProtocolError(t, typer, kb, uint32(virtualkeyboard.ZwpVirtualKeyboardV1ErrorNoKeymap))
103+}

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