detached 8544ffa

feat(wayland): add text-input v3 and input-method v2 (#41)

* feat(wayland): add text-input v3 and input-method v2

Text input focus follows keyboard focus. One input method serves the enabled text input: state is forwarded on commit, preedit/commit/delete go back with the client commit count as done serial. A second input method gets unavailable. The keyboard grab routes focused keys to the input method; input popups get the cursor rectangle.

* test(app): add testime input method and headless IME end-to-end test

* docs: describe input method support and testime

* fix(wayland): order text input focus, drop stale IME commits and grab-owned releases

Leave events now precede every enter. An input method commit from before the last activate no longer reaches the next text input. A key pressed through the keyboard grab keeps its release away from the client after the grab ends. An unavailable input method's popup request has no effect.

* test(app): cover repeated IME activations and testime exit

testime serves every activation and takes -count; the headless test checks two commits, the testime exit status, and skips cleanly without Vulkan.
commit
8544ffa57a9db3983057193a07e930116bfd3d19
author
bnema · · 2026-09-28 11:45 UTC
committer
GitHub · · 2026-09-28 11:45 UTC
parent
74d746f
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +1519 -4
M README.md +3 -3
@@ -27,7 +27,7 @@ The design is deliberately small, and it will stay small: no animations, no them
2727 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace.
2828 - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window.
2929 - **Multi-monitor.** Each output has its own workspaces. When an output is unplugged its workspaces move to another one, and return when it is plugged back. Fractional scale per output.
30-- **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, grim, cliphist, swayidle and wlr-randr work through their standard protocols. See [Desktop integration](docs/desktop.md).
30+- **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, grim, cliphist, swayidle, wlr-randr and input methods such as fcitx5 work through their standard protocols. See [Desktop integration](docs/desktop.md).
3131 - **Config.** One `key = value` file, reloaded on save without closing windows. A JSON state file describes outputs, workspaces and windows for scripts.
3232
3333 Config example:
@@ -96,8 +96,8 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil
9696 ## Documentation
9797
9898 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
99-- [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts.
100-- [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts.
99+- [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts.
100+- [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods.
101101 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
102102
103103 ## Why not Rust?
@@ -27,7 +27,7 @@ The design is deliberately small, and it will stay small: no animations, no them
27 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace.27 - **Column tiling.** Up to `max-columns` windows share the screen, as in Sway; past that, columns scroll to the right, as in Niri. Limits are set per output or per workspace.
28 - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window.28 - **Workspaces.** Numbered workspaces are created and removed as needed. Named workspaces declare their columns (width and command); NeferWL starts the commands and places each window.
29 - **Multi-monitor.** Each output has its own workspaces. When an output is unplugged its workspaces move to another one, and return when it is plugged back. Fractional scale per output.29 - **Multi-monitor.** Each output has its own workspaces. When an output is unplugged its workspaces move to another one, and return when it is plugged back. Fractional scale per output.
30-- **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, grim, cliphist, swayidle and wlr-randr work through their standard protocols. See [Desktop integration](docs/desktop.md).30+- **External clients.** No built-in bar, launcher or notifications. Waybar, fuzzel, mako, grim, cliphist, swayidle, wlr-randr and input methods such as fcitx5 work through their standard protocols. See [Desktop integration](docs/desktop.md).
31 - **Config.** One `key = value` file, reloaded on save without closing windows. A JSON state file describes outputs, workspaces and windows for scripts.31 - **Config.** One `key = value` file, reloaded on save without closing windows. A JSON state file describes outputs, workspaces and windows for scripts.
32 32
33 Config example:33 Config example:
@@ -96,8 +96,8 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil
96 ## Documentation96 ## Documentation
97 97
98 - [Configuration](docs/config.md): every key, action and default bind, and HDR.98 - [Configuration](docs/config.md): every key, action and default bind, and HDR.
99-- [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts.99+- [Desktop integration](docs/desktop.md): running commands, clipboard, input methods, X11 apps, idle and screen off, bars, and the state file for scripts.
100-- [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts.100+- [Headless mode](docs/headless.md): run without a screen, take screenshots, play input scripts and test input methods.
101 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.101 - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers.
102 102
103 ## Why not Rust?103 ## Why not Rust?
M docs/desktop.md +10 -0
@@ -47,6 +47,16 @@ cliphist list | fuzzel --dmenu | cliphist decode | wl-copy
4747
4848 Drag and drop is not supported yet.
4949
50+## Input methods
51+
52+NeferWL supports `zwp_text_input_v3` for apps and `zwp_input_method_v2` for one input method, such as [fcitx5](https://fcitx-im.org) or IBus. Start the input method at session start:
53+
54+```text
55+startup = fcitx5 -d
56+```
57+
58+Text input follows keyboard focus. The input method can grab the keyboard to compose text from keys (CJK, dead keys); the focused app then receives the composed text. A second input method gets `unavailable` until the first one exits. Candidate popups (`zwp_input_popup_surface_v2`) receive the text cursor position but are not drawn yet.
59+
5060 ## X11 apps
5161
5262 X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, found in `PATH`. NeferWL opens an X11 display, sets `DISPLAY` for the programs it starts, and starts xwayland-satellite when the first X11 app connects. If xwayland-satellite exits, the next X11 app starts it again.
@@ -47,6 +47,16 @@ cliphist list | fuzzel --dmenu | cliphist decode | wl-copy
47 47
48 Drag and drop is not supported yet.48 Drag and drop is not supported yet.
49 49
50+## Input methods
51+
52+NeferWL supports `zwp_text_input_v3` for apps and `zwp_input_method_v2` for one input method, such as [fcitx5](https://fcitx-im.org) or IBus. Start the input method at session start:
53+
54+```text
55+startup = fcitx5 -d
56+```
57+
58+Text input follows keyboard focus. The input method can grab the keyboard to compose text from keys (CJK, dead keys); the focused app then receives the composed text. A second input method gets `unavailable` until the first one exits. Candidate popups (`zwp_input_popup_surface_v2`) receive the text cursor position but are not drawn yet.
59+
50 ## X11 apps60 ## X11 apps
51 61
52 X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, found in `PATH`. NeferWL opens an X11 display, sets `DISPLAY` for the programs it starts, and starts xwayland-satellite when the first X11 app connects. If xwayland-satellite exits, the next X11 app starts it again.62 X11 apps such as Steam run through [xwayland-satellite](https://github.com/Supreeeme/xwayland-satellite) 0.7 or later, found in `PATH`. NeferWL opens an X11 display, sets `DISPLAY` for the programs it starts, and starts xwayland-satellite when the first X11 app connects. If xwayland-satellite exits, the next X11 app starts it again.
M docs/headless.md +9 -0
@@ -19,4 +19,13 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image
1919 - `move <x> <y>` (output coordinates)
2020 - `click [left|right|middle]`, `down <button>`, `up <button>`
2121
22+`examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text:
23+
24+```sh
25+neferwl --backend=headless
26+WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -commit 日本
27+```
28+
29+`-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit.
30+
2231 The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.
@@ -19,4 +19,13 @@ Screenshots through `grim` are verified with `zwlr_screencopy_v1` and `ext_image
19 - `move <x> <y>` (output coordinates)19 - `move <x> <y>` (output coordinates)
20 - `click [left|right|middle]`, `down <button>`, `up <button>`20 - `click [left|right|middle]`, `down <button>`, `up <button>`
21 21
22+`examples/testime` is a minimal input method for testing text input. Each time an app enables a text input, it sends a preedit string, then commits the final text:
23+
24+```sh
25+neferwl --backend=headless
26+WAYLAND_DISPLAY=<name from the log> go run ./examples/testime -preedit nihon -commit 日本
27+```
28+
29+`-count <n>` exits after n commits, and `-delay` sets the time between preedit and commit.
30+
22 The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.31 The headless backend does not draw the cursor on screen. After a layout change, pointer focus updates on the next move.
A examples/testime/main.go +146 -0
@@ -0,0 +1,146 @@
1+// Command testime is a minimal zwp_input_method_v2 client for testing text
2+// input. Each time a text input is activated it sends a preedit string,
3+// then commits the final text:
4+//
5+// WAYLAND_DISPLAY=wayland-1 go run ./examples/testime -preedit nihon -commit 日本
6+package main
7+
8+import (
9+ "errors"
10+ "flag"
11+ "fmt"
12+ "os"
13+ "time"
14+
15+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
16+ "github.com/bnema/wlturbo"
17+)
18+
19+func main() {
20+ preedit := flag.String("preedit", "nihon", "preedit string sent first")
21+ commit := flag.String("commit", "日本", "text committed after the preedit")
22+ delay := flag.Duration("delay", 100*time.Millisecond, "time between preedit and commit")
23+ count := flag.Int("count", 0, "exit after this many commits; 0 runs until the compositor closes")
24+ flag.Parse()
25+ if err := run(*preedit, *commit, *delay, *count); err != nil {
26+ fmt.Fprintln(os.Stderr, "testime:", err)
27+ os.Exit(1)
28+ }
29+}
30+
31+// inputMethod follows the activate/done cycle of zwp_input_method_v2.
32+type inputMethod struct {
33+ wlturbo.BaseProxy
34+ c *wlturbo.Display
35+ dones uint32
36+ active bool // state of the last done
37+ activating bool // activate seen, not yet applied by done
38+ unavail bool
39+ activated bool // activate seen since the last done
40+ serve bool // an activation applied by done, not yet served
41+}
42+
43+func (m *inputMethod) Dispatch(e *wlturbo.Event) {
44+ switch uint32(e.Opcode) {
45+ case inputmethod.ZwpInputMethodV2EventActivate:
46+ // Every activate starts a new text input session, even while
47+ // active: an app enabling its text input again gets one.
48+ m.activating, m.activated = true, true
49+ case inputmethod.ZwpInputMethodV2EventDeactivate:
50+ m.activating, m.activated = false, false
51+ case inputmethod.ZwpInputMethodV2EventDone:
52+ // Requests only count for an activation once its done is in:
53+ // the compositor drops commits with an older serial.
54+ m.dones++
55+ m.active = m.activating
56+ m.serve = m.serve && m.active || m.activated
57+ m.activated = false
58+ case inputmethod.ZwpInputMethodV2EventUnavailable:
59+ m.unavail = true
60+ }
61+}
62+
63+// ignored drops the events of an object testime does not use.
64+type ignored struct{ wlturbo.BaseProxy }
65+
66+func (*ignored) Dispatch(*wlturbo.Event) {}
67+
68+func (m *inputMethod) send(op uint32, args ...any) error {
69+ return m.c.SendRequest(m.ID(), uint16(op), args...)
70+}
71+
72+func run(preedit, commit string, delay time.Duration, count int) error {
73+ c, err := wlturbo.Connect("")
74+ if err != nil {
75+ return err
76+ }
77+ defer c.Close()
78+ if err := c.Roundtrip(); err != nil {
79+ return err
80+ }
81+ bind := func(iface string) (uint32, error) {
82+ g, ok := c.Registry().FindGlobal(iface)
83+ if !ok {
84+ return 0, fmt.Errorf("compositor has no %s", iface)
85+ }
86+ return c.Registry().BindID(g.Name, g.Interface, 1)
87+ }
88+ seat, err := bind("wl_seat")
89+ if err != nil {
90+ return err
91+ }
92+ // wlturbo fails on events for unregistered objects: the seat sends some.
93+ seatProxy := &ignored{}
94+ seatProxy.SetID(seat)
95+ c.Context().Register(seatProxy)
96+ manager, err := bind("zwp_input_method_manager_v2")
97+ if err != nil {
98+ return err
99+ }
100+ m := &inputMethod{c: c}
101+ m.SetID(c.AllocateID())
102+ c.Context().Register(m)
103+ if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil {
104+ return err
105+ }
106+ fmt.Fprintln(os.Stderr, "testime: ready")
107+ for commits := 0; ; {
108+ if err := c.Dispatch(); err != nil {
109+ return err
110+ }
111+ if m.unavail {
112+ return errors.New("another input method is running")
113+ }
114+ if !m.serve {
115+ continue
116+ }
117+ m.serve = false
118+ if err := m.send(inputmethod.ZwpInputMethodV2RequestSetPreeditString, preedit, int32(len(preedit)), int32(len(preedit))); err != nil {
119+ return err
120+ }
121+ if err := m.send(inputmethod.ZwpInputMethodV2RequestCommit, m.dones); err != nil {
122+ return err
123+ }
124+ time.Sleep(delay)
125+ // Events that arrived meanwhile update the done count first.
126+ if err := c.Roundtrip(); err != nil {
127+ return err
128+ }
129+ if !m.active {
130+ continue
131+ }
132+ if err := m.send(inputmethod.ZwpInputMethodV2RequestCommitString, commit); err != nil {
133+ return err
134+ }
135+ if err := m.send(inputmethod.ZwpInputMethodV2RequestCommit, m.dones); err != nil {
136+ return err
137+ }
138+ if err := c.Roundtrip(); err != nil {
139+ return err
140+ }
141+ fmt.Fprintf(os.Stderr, "testime: committed %q\n", commit)
142+ if commits++; count > 0 && commits == count {
143+ return nil
144+ }
145+ }
146+}
@@ -0,0 +1,146 @@
1+// Command testime is a minimal zwp_input_method_v2 client for testing text
2+// input. Each time a text input is activated it sends a preedit string,
3+// then commits the final text:
4+//
5+// WAYLAND_DISPLAY=wayland-1 go run ./examples/testime -preedit nihon -commit 日本
6+package main
7+
8+import (
9+ "errors"
10+ "flag"
11+ "fmt"
12+ "os"
13+ "time"
14+
15+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
16+ "github.com/bnema/wlturbo"
17+)
18+
19+func main() {
20+ preedit := flag.String("preedit", "nihon", "preedit string sent first")
21+ commit := flag.String("commit", "日本", "text committed after the preedit")
22+ delay := flag.Duration("delay", 100*time.Millisecond, "time between preedit and commit")
23+ count := flag.Int("count", 0, "exit after this many commits; 0 runs until the compositor closes")
24+ flag.Parse()
25+ if err := run(*preedit, *commit, *delay, *count); err != nil {
26+ fmt.Fprintln(os.Stderr, "testime:", err)
27+ os.Exit(1)
28+ }
29+}
30+
31+// inputMethod follows the activate/done cycle of zwp_input_method_v2.
32+type inputMethod struct {
33+ wlturbo.BaseProxy
34+ c *wlturbo.Display
35+ dones uint32
36+ active bool // state of the last done
37+ activating bool // activate seen, not yet applied by done
38+ unavail bool
39+ activated bool // activate seen since the last done
40+ serve bool // an activation applied by done, not yet served
41+}
42+
43+func (m *inputMethod) Dispatch(e *wlturbo.Event) {
44+ switch uint32(e.Opcode) {
45+ case inputmethod.ZwpInputMethodV2EventActivate:
46+ // Every activate starts a new text input session, even while
47+ // active: an app enabling its text input again gets one.
48+ m.activating, m.activated = true, true
49+ case inputmethod.ZwpInputMethodV2EventDeactivate:
50+ m.activating, m.activated = false, false
51+ case inputmethod.ZwpInputMethodV2EventDone:
52+ // Requests only count for an activation once its done is in:
53+ // the compositor drops commits with an older serial.
54+ m.dones++
55+ m.active = m.activating
56+ m.serve = m.serve && m.active || m.activated
57+ m.activated = false
58+ case inputmethod.ZwpInputMethodV2EventUnavailable:
59+ m.unavail = true
60+ }
61+}
62+
63+// ignored drops the events of an object testime does not use.
64+type ignored struct{ wlturbo.BaseProxy }
65+
66+func (*ignored) Dispatch(*wlturbo.Event) {}
67+
68+func (m *inputMethod) send(op uint32, args ...any) error {
69+ return m.c.SendRequest(m.ID(), uint16(op), args...)
70+}
71+
72+func run(preedit, commit string, delay time.Duration, count int) error {
73+ c, err := wlturbo.Connect("")
74+ if err != nil {
75+ return err
76+ }
77+ defer c.Close()
78+ if err := c.Roundtrip(); err != nil {
79+ return err
80+ }
81+ bind := func(iface string) (uint32, error) {
82+ g, ok := c.Registry().FindGlobal(iface)
83+ if !ok {
84+ return 0, fmt.Errorf("compositor has no %s", iface)
85+ }
86+ return c.Registry().BindID(g.Name, g.Interface, 1)
87+ }
88+ seat, err := bind("wl_seat")
89+ if err != nil {
90+ return err
91+ }
92+ // wlturbo fails on events for unregistered objects: the seat sends some.
93+ seatProxy := &ignored{}
94+ seatProxy.SetID(seat)
95+ c.Context().Register(seatProxy)
96+ manager, err := bind("zwp_input_method_manager_v2")
97+ if err != nil {
98+ return err
99+ }
100+ m := &inputMethod{c: c}
101+ m.SetID(c.AllocateID())
102+ c.Context().Register(m)
103+ if err := c.SendRequest(manager, uint16(inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod), seat, m.ID()); err != nil {
104+ return err
105+ }
106+ fmt.Fprintln(os.Stderr, "testime: ready")
107+ for commits := 0; ; {
108+ if err := c.Dispatch(); err != nil {
109+ return err
110+ }
111+ if m.unavail {
112+ return errors.New("another input method is running")
113+ }
114+ if !m.serve {
115+ continue
116+ }
117+ m.serve = false
118+ if err := m.send(inputmethod.ZwpInputMethodV2RequestSetPreeditString, preedit, int32(len(preedit)), int32(len(preedit))); err != nil {
119+ return err
120+ }
121+ if err := m.send(inputmethod.ZwpInputMethodV2RequestCommit, m.dones); err != nil {
122+ return err
123+ }
124+ time.Sleep(delay)
125+ // Events that arrived meanwhile update the done count first.
126+ if err := c.Roundtrip(); err != nil {
127+ return err
128+ }
129+ if !m.active {
130+ continue
131+ }
132+ if err := m.send(inputmethod.ZwpInputMethodV2RequestCommitString, commit); err != nil {
133+ return err
134+ }
135+ if err := m.send(inputmethod.ZwpInputMethodV2RequestCommit, m.dones); err != nil {
136+ return err
137+ }
138+ if err := c.Roundtrip(); err != nil {
139+ return err
140+ }
141+ fmt.Fprintf(os.Stderr, "testime: committed %q\n", commit)
142+ if commits++; count > 0 && commits == count {
143+ return nil
144+ }
145+ }
146+}
M internal/adapters/wayland/globals.go +2 -0
@@ -31,6 +31,8 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
3131 func() error { return registerCapture(d, s) },
3232 func() error { return registerActivation(d, s) },
3333 func() error { return registerVirtualKeyboard(d, s) },
34+ func() error { return registerTextInput(d, s) },
35+ func() error { return registerInputMethod(d, s) },
3436 func() error {
3537 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
3638 },
@@ -31,6 +31,8 @@ func registerGlobals(d *server.Display, o Options, s *Server) error {
31 func() error { return registerCapture(d, s) },31 func() error { return registerCapture(d, s) },
32 func() error { return registerActivation(d, s) },32 func() error { return registerActivation(d, s) },
33 func() error { return registerVirtualKeyboard(d, s) },33 func() error { return registerVirtualKeyboard(d, s) },
34+ func() error { return registerTextInput(d, s) },
35+ func() error { return registerInputMethod(d, s) },
34 func() error {36 func() error {
35 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })37 return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) })
36 },38 },
M internal/adapters/wayland/server.go +13 -1
@@ -125,6 +125,7 @@ type Server struct {
125125 modState ports.ModState
126126 keyboards map[server.Client][]*wayland.Keyboard
127127 heldKeys map[uint32]bool
128+ grabKeys map[uint32]bool // pressed through the input method grab
128129 keymapFD int
129130 keymapSize uint32
130131 keymapText string // the seat keymap, to compare virtual keymaps with
@@ -192,6 +193,11 @@ type Server struct {
192193 constraints map[*surface]*constraint
193194 constraint *constraint // the active one
194195 positioners map[*server.Resource]*positioner
196+ // Text input and input method (textinput.go): activeText is the
197+ // enabled text input the input method serves.
198+ textInputs []*textInput
199+ activeText *textInput
200+ inputMethod *inputMethod
195201 }
196202
197203 func removeItem[T comparable](list []T, v T) []T {
@@ -633,6 +639,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
633639 s.changeFocus(c.ID)
634640 }
635641 case ports.ForwardKey:
642+ if s.grabKey(c) {
643+ return
644+ }
636645 _, keyboards := s.focusTarget(c.ID)
637646 if c.ID != s.focused || len(keyboards) == 0 {
638647 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
@@ -815,6 +824,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
815824 }
816825 }
817826 }
827+ s.sendGrabKeymap(false)
818828 s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")
819829 return
820830 }
@@ -830,7 +840,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
830840 s.keymapOwner = nil
831841 focused := s.focused
832842 s.changeFocus(0)
833- s.heldKeys = nil
843+ s.heldKeys, s.grabKeys = nil, nil
834844 s.modState = ports.ModState{}
835845 for _, list := range s.keyboards {
836846 for _, k := range list {
@@ -840,6 +850,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
840850 }
841851 }
842852 }
853+ s.sendGrabKeymap(true)
843854 s.changeFocus(focused)
844855 s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")
845856 }
@@ -880,6 +891,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
880891 s.sendModifiers(k)
881892 }
882893 }
894+ s.textInputFocus()
883895 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
884896 s.updateConstraint()
885897 }
@@ -125,6 +125,7 @@ type Server struct {
125 modState ports.ModState125 modState ports.ModState
126 keyboards map[server.Client][]*wayland.Keyboard126 keyboards map[server.Client][]*wayland.Keyboard
127 heldKeys map[uint32]bool127 heldKeys map[uint32]bool
128+ grabKeys map[uint32]bool // pressed through the input method grab
128 keymapFD int129 keymapFD int
129 keymapSize uint32130 keymapSize uint32
130 keymapText string // the seat keymap, to compare virtual keymaps with131 keymapText string // the seat keymap, to compare virtual keymaps with
@@ -192,6 +193,11 @@ type Server struct {
192 constraints map[*surface]*constraint193 constraints map[*surface]*constraint
193 constraint *constraint // the active one194 constraint *constraint // the active one
194 positioners map[*server.Resource]*positioner195 positioners map[*server.Resource]*positioner
196+ // Text input and input method (textinput.go): activeText is the
197+ // enabled text input the input method serves.
198+ textInputs []*textInput
199+ activeText *textInput
200+ inputMethod *inputMethod
195 }201 }
196 202
197 func removeItem[T comparable](list []T, v T) []T {203 func removeItem[T comparable](list []T, v T) []T {
@@ -633,6 +639,9 @@ func (s *Server) apply(cmd ports.ClientCommand) {
633 s.changeFocus(c.ID)639 s.changeFocus(c.ID)
634 }640 }
635 case ports.ForwardKey:641 case ports.ForwardKey:
642+ if s.grabKey(c) {
643+ return
644+ }
636 _, keyboards := s.focusTarget(c.ID)645 _, keyboards := s.focusTarget(c.ID)
637 if c.ID != s.focused || len(keyboards) == 0 {646 if c.ID != s.focused || len(keyboards) == 0 {
638 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")647 s.log.Debug().Uint64("id", uint64(c.ID)).Msg("ignored forward key")
@@ -815,6 +824,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
815 }824 }
816 }825 }
817 }826 }
827+ s.sendGrabKeymap(false)
818 s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")828 s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated")
819 return829 return
820 }830 }
@@ -830,7 +840,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
830 s.keymapOwner = nil840 s.keymapOwner = nil
831 focused := s.focused841 focused := s.focused
832 s.changeFocus(0)842 s.changeFocus(0)
833- s.heldKeys = nil843+ s.heldKeys, s.grabKeys = nil, nil
834 s.modState = ports.ModState{}844 s.modState = ports.ModState{}
835 for _, list := range s.keyboards {845 for _, list := range s.keyboards {
836 for _, k := range list {846 for _, k := range list {
@@ -840,6 +850,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) {
840 }850 }
841 }851 }
842 }852 }
853+ s.sendGrabKeymap(true)
843 s.changeFocus(focused)854 s.changeFocus(focused)
844 s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")855 s.log.Info().Uint32("size", size).Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("keymap updated")
845 }856 }
@@ -880,6 +891,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
880 s.sendModifiers(k)891 s.sendModifiers(k)
881 }892 }
882 }893 }
894+ s.textInputFocus()
883 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")895 s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus")
884 s.updateConstraint()896 s.updateConstraint()
885 }897 }
M internal/adapters/wayland/surface.go +1 -0
@@ -21,6 +21,7 @@ const (
2121 roleSubsurface
2222 roleLayer
2323 roleCursor
24+ roleInputPopup
2425 )
2526
2627 type surface struct {
@@ -21,6 +21,7 @@ const (
21 roleSubsurface21 roleSubsurface
22 roleLayer22 roleLayer
23 roleCursor23 roleCursor
24+ roleInputPopup
24 )25 )
25 26
26 type surface struct {27 type surface struct {
A internal/adapters/wayland/textinput.go +532 -0
@@ -0,0 +1,532 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
6+ "github.com/bnema/purego-libwayland/protocol/textinput"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// Text input (zwp_text_input_v3) lets clients take IME text; one input
12+// method (zwp_input_method_v2, e.g. fcitx5 or IBus) composes it. Text
13+// input focus follows keyboard focus. The first focused text input that
14+// commits enable is the active one: the input method serves only it.
15+
16+// maxIMEText is the spec limit for strings carried by both protocols.
17+const maxIMEText = 4000
18+
19+// textState is the double-buffered state of a text input.
20+type textState struct {
21+ enabled bool
22+ surrounding string
23+ cursor, anchor uint32
24+ hasSurrounding bool
25+ cause uint32
26+ hint, purpose uint32
27+ rect ports.Rect
28+ hasRect bool
29+}
30+
31+type textInput struct {
32+ server *Server
33+ res *textinput.ZwpTextInputV3
34+ client server.Client
35+ // focus is the entered surface, nil after leave.
36+ focus *wayland.Surface
37+ pending, current textState
38+ // toggled: an enable or disable request is pending.
39+ toggled bool
40+ // serial counts commit requests, the serial of done events.
41+ serial uint32
42+}
43+
44+func registerTextInput(d *server.Display, s *Server) error {
45+ return textinput.NewZwpTextInputManagerV3Global(d, 1, func(c server.Client, v, id uint32) {
46+ _, _ = textinput.NewZwpTextInputManagerV3(c, int32(v), id, textInputManager{s})
47+ })
48+}
49+
50+type textInputManager struct{ server *Server }
51+
52+func (textInputManager) Destroy(*textinput.ZwpTextInputManagerV3) {}
53+
54+func (m textInputManager) GetTextInput(r *textinput.ZwpTextInputManagerV3, id uint32, _ *wayland.Seat) {
55+ s := m.server
56+ t := &textInput{server: s, client: r.Client()}
57+ res, err := textinput.NewZwpTextInputV3(r.Client(), r.Version(), id, t)
58+ if err != nil {
59+ return
60+ }
61+ t.res = res
62+ s.textInputs = append(s.textInputs, t)
63+ res.OnDestroy = func() {
64+ s.textInputs = removeItem(s.textInputs, t)
65+ if s.activeText == t {
66+ s.deactivateText()
67+ }
68+ t.focus = nil
69+ }
70+ if surf, _ := s.focusTarget(s.focused); surf != nil && surf.Client() == t.client {
71+ t.enter(surf)
72+ }
73+}
74+
75+func (*textInput) Destroy(*textinput.ZwpTextInputV3) {}
76+
77+// Enable resets the pending state, as the spec asks.
78+func (t *textInput) Enable(*textinput.ZwpTextInputV3) {
79+ t.pending, t.toggled = textState{enabled: true}, true
80+}
81+
82+// Disable invalidates the pending state.
83+func (t *textInput) Disable(*textinput.ZwpTextInputV3) {
84+ t.pending, t.toggled = textState{}, true
85+}
86+
87+// SetSurroundingText ignores text past the spec limit or a cursor or
88+// anchor outside it.
89+func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) {
90+ if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) {
91+ t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored")
92+ return
93+ }
94+ p := &t.pending
95+ p.surrounding, p.cursor, p.anchor, p.hasSurrounding = text, uint32(cursor), uint32(anchor), true
96+}
97+
98+func (t *textInput) SetTextChangeCause(_ *textinput.ZwpTextInputV3, cause uint32) {
99+ t.pending.cause = cause
100+}
101+
102+func (t *textInput) SetContentType(_ *textinput.ZwpTextInputV3, hint, purpose uint32) {
103+ t.pending.hint, t.pending.purpose = hint, purpose
104+}
105+
106+func (t *textInput) SetCursorRectangle(_ *textinput.ZwpTextInputV3, x, y, w, h int32) {
107+ t.pending.rect, t.pending.hasRect = ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, true
108+}
109+
110+// Commit applies the pending state. Every commit counts for the done
111+// serial, but after leave the state is ignored until the next enter.
112+func (t *textInput) Commit(*textinput.ZwpTextInputV3) {
113+ t.serial++
114+ if t.focus == nil {
115+ return
116+ }
117+ reenabled := t.toggled && t.pending.enabled
118+ t.current = t.pending
119+ t.pending.cause = uint32(textinput.ZwpTextInputV3ChangeCauseInputMethod)
120+ t.toggled = false
121+ t.server.textCommitted(t, reenabled)
122+}
123+
124+// Version 2 requests: the global is version 1.
125+func (*textInput) SetAvailableActions(*textinput.ZwpTextInputV3, []byte) {}
126+func (*textInput) ShowInputPanel(*textinput.ZwpTextInputV3) {}
127+func (*textInput) HideInputPanel(*textinput.ZwpTextInputV3) {}
128+
129+// enter focuses the text input on surf; its state starts over.
130+func (t *textInput) enter(surf *wayland.Surface) {
131+ t.focus = surf
132+ t.pending, t.current, t.toggled = textState{}, textState{}, false
133+ t.res.SendEnter(surf)
134+}
135+
136+// leave clears the focus; a destroyed surface gets no leave event.
137+func (t *textInput) leave() {
138+ s := t.server
139+ if s.activeText == t {
140+ s.deactivateText()
141+ }
142+ if t.focus.Resource.Alive() {
143+ t.res.SendLeave(t.focus)
144+ }
145+ t.focus = nil
146+ t.pending, t.current, t.toggled = textState{}, textState{}, false
147+}
148+
149+// textCommitted activates, updates or deactivates the input method for a
150+// committed text input. Enabling a second text input is ignored.
151+func (s *Server) textCommitted(t *textInput, reenabled bool) {
152+ switch {
153+ case t.current.enabled && s.activeText == nil:
154+ s.activeText = t
155+ s.activateIM()
156+ case t.current.enabled && s.activeText == t:
157+ if reenabled {
158+ s.activateIM()
159+ } else {
160+ s.sendIMState()
161+ }
162+ case !t.current.enabled && s.activeText == t:
163+ s.deactivateText()
164+ }
165+}
166+
167+// textInputFocus moves text input focus to the keyboard focus: leave for
168+// text inputs on another surface, then enter for those of the focused
169+// client, as the spec orders them.
170+func (s *Server) textInputFocus() {
171+ surf, _ := s.focusTarget(s.focused)
172+ for _, t := range s.textInputs {
173+ if t.focus != nil && (surf == nil || t.focus.Resource != surf.Resource) {
174+ t.leave()
175+ }
176+ }
177+ if surf == nil {
178+ return
179+ }
180+ for _, t := range s.textInputs {
181+ if t.focus == nil && t.client == surf.Client() {
182+ t.enter(surf)
183+ }
184+ }
185+}
186+
187+// deactivateText drops the active text input and tells the input method.
188+func (s *Server) deactivateText() {
189+ s.activeText = nil
190+ im := s.inputMethod
191+ if im == nil {
192+ return
193+ }
194+ im.res.SendDeactivate()
195+ im.done()
196+}
197+
198+// activateIM sends activate and the active text input's state.
199+func (s *Server) activateIM() {
200+ im := s.inputMethod
201+ if im == nil || s.activeText == nil {
202+ return
203+ }
204+ im.pending = imState{}
205+ im.activated = im.dones
206+ im.res.SendActivate()
207+ s.sendIMState()
208+}
209+
210+// sendIMState sends the active text input's state, then done.
211+func (s *Server) sendIMState() {
212+ im, t := s.inputMethod, s.activeText
213+ if im == nil || t == nil {
214+ return
215+ }
216+ c := t.current
217+ if c.hasSurrounding {
218+ im.res.SendSurroundingText(c.surrounding, c.cursor, c.anchor)
219+ }
220+ im.res.SendTextChangeCause(c.cause)
221+ im.res.SendContentType(c.hint, c.purpose)
222+ for _, p := range im.popups {
223+ p.sendRect()
224+ }
225+ im.done()
226+}
227+
228+// imState is the double-buffered state of the input method.
229+type imState struct {
230+ preedit string
231+ begin, end int32
232+ hasPreedit bool
233+ commit string
234+ hasCommit bool
235+ before, after uint32
236+}
237+
238+type inputMethod struct {
239+ server *Server
240+ res *inputmethod.ZwpInputMethodV2
241+ // inert: another input method was bound first; requests are ignored.
242+ inert bool
243+ dones uint32
244+ // activated is dones when the last activate was sent: a commit with
245+ // a serial up to it predates the activation.
246+ activated uint32
247+ pending imState
248+ grab *keyboardGrab
249+ popups []*inputPopup
250+}
251+
252+func registerInputMethod(d *server.Display, s *Server) error {
253+ return inputmethod.NewZwpInputMethodManagerV2Global(d, 1, func(c server.Client, v, id uint32) {
254+ _, _ = inputmethod.NewZwpInputMethodManagerV2(c, int32(v), id, inputMethodManager{s})
255+ })
256+}
257+
258+type inputMethodManager struct{ server *Server }
259+
260+func (inputMethodManager) Destroy(*inputmethod.ZwpInputMethodManagerV2) {}
261+
262+// GetInputMethod makes the first input method the seat's; later ones get
263+// unavailable until it is gone.
264+func (m inputMethodManager) GetInputMethod(r *inputmethod.ZwpInputMethodManagerV2, _ *wayland.Seat, id uint32) {
265+ s := m.server
266+ im := &inputMethod{server: s, inert: s.inputMethod != nil}
267+ res, err := inputmethod.NewZwpInputMethodV2(r.Client(), r.Version(), id, im)
268+ if err != nil {
269+ return
270+ }
271+ im.res = res
272+ if im.inert {
273+ res.SendUnavailable()
274+ s.log.Debug().Int("pid", r.Client().PID()).Msg("input method unavailable")
275+ return
276+ }
277+ s.inputMethod = im
278+ res.OnDestroy = func() { s.inputMethodGone(im) }
279+ s.log.Info().Int("pid", r.Client().PID()).Msg("input method bound")
280+ s.activateIM()
281+}
282+
283+// inputMethodGone frees the seat slot. A grab ends; the active text
284+// input's preedit is cleared by an empty done.
285+func (s *Server) inputMethodGone(im *inputMethod) {
286+ if s.inputMethod != im {
287+ return
288+ }
289+ s.inputMethod = nil
290+ if im.grab != nil {
291+ im.grab = nil
292+ s.grabEnded()
293+ }
294+ if t := s.activeText; t != nil {
295+ t.res.SendDone(t.serial)
296+ }
297+ s.log.Info().Msg("input method gone")
298+}
299+
300+// done sends done and counts it for commit serials.
301+func (im *inputMethod) done() {
302+ im.res.SendDone()
303+ im.dones++
304+}
305+
306+func (im *inputMethod) CommitString(_ *inputmethod.ZwpInputMethodV2, text string) {
307+ if im.inert || !im.fits(text) {
308+ return
309+ }
310+ im.pending.commit, im.pending.hasCommit = text, true
311+}
312+
313+func (im *inputMethod) SetPreeditString(_ *inputmethod.ZwpInputMethodV2, text string, begin, end int32) {
314+ if im.inert || !im.fits(text) {
315+ return
316+ }
317+ p := &im.pending
318+ p.preedit, p.begin, p.end, p.hasPreedit = text, begin, end, true
319+}
320+
321+func (im *inputMethod) fits(text string) bool {
322+ if len(text) > maxIMEText {
323+ im.server.log.Debug().Int("bytes", len(text)).Msg("input method text too long")
324+ return false
325+ }
326+ return true
327+}
328+
329+func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) {
330+ if im.inert {
331+ return
332+ }
333+ im.pending.before, im.pending.after = before, after
334+}
335+
336+// Commit sends the pending state to the active text input, in the
337+// spec's order, with done carrying the text input's commit count.
338+// A commit from before the last activate is dropped: its text belongs to
339+// the previous text input. Within one activation, like wlroots, a stale
340+// serial is only logged: dropping the commit would lose typed text.
341+func (im *inputMethod) Commit(_ *inputmethod.ZwpInputMethodV2, serial uint32) {
342+ if im.inert {
343+ return
344+ }
345+ st := im.pending
346+ im.pending = imState{}
347+ s := im.server
348+ t := s.activeText
349+ if t == nil {
350+ return
351+ }
352+ if serial <= im.activated {
353+ s.log.Debug().Uint32("serial", serial).Uint32("activated", im.activated).Msg("input method commit before activation dropped")
354+ return
355+ }
356+ if serial != im.dones {
357+ s.log.Debug().Uint32("serial", serial).Uint32("done", im.dones).Msg("input method commit with stale serial")
358+ }
359+ if st.hasPreedit {
360+ t.res.SendPreeditString(st.preedit, st.begin, st.end)
361+ }
362+ if st.hasCommit {
363+ t.res.SendCommitString(st.commit)
364+ }
365+ if st.before != 0 || st.after != 0 {
366+ t.res.SendDeleteSurroundingText(st.before, st.after)
367+ }
368+ t.res.SendDone(t.serial)
369+}
370+
371+// GetInputPopupSurface gives a surface the input_popup role. The popup
372+// learns the text cursor rectangle; it is not drawn.
373+func (im *inputMethod) GetInputPopupSurface(r *inputmethod.ZwpInputMethodV2, id uint32, surf *wayland.Surface) {
374+ s := im.server
375+ if im.inert {
376+ // An unavailable input method's requests have no effect.
377+ _, _ = inputmethod.NewZwpInputPopupSurfaceV2(r.Client(), r.Version(), id, &inputPopup{im: im})
378+ return
379+ }
380+ if surf == nil {
381+ return
382+ }
383+ state := s.surfaces[surf.Resource]
384+ if state == nil {
385+ return
386+ }
387+ if state.kind != roleNone {
388+ r.PostError(uint32(inputmethod.ZwpInputMethodV2ErrorRole), "surface already has a role")
389+ return
390+ }
391+ p := &inputPopup{im: im}
392+ res, err := inputmethod.NewZwpInputPopupSurfaceV2(r.Client(), r.Version(), id, p)
393+ if err != nil {
394+ return
395+ }
396+ state.kind, p.res = roleInputPopup, res
397+ im.popups = append(im.popups, p)
398+ res.OnDestroy = func() { im.popups = removeItem(im.popups, p) }
399+ if s.activeText != nil {
400+ p.sendRect()
401+ }
402+}
403+
404+// GrabKeyboard routes seat keys to the input method. A second grab
405+// while one is held stays inert.
406+func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) {
407+ g := &keyboardGrab{}
408+ res, err := inputmethod.NewZwpInputMethodKeyboardGrabV2(r.Client(), r.Version(), id, g)
409+ if err != nil || im.inert || im.grab != nil {
410+ return
411+ }
412+ g.res, im.grab = res, g
413+ res.OnDestroy = func() {
414+ if im.grab == g {
415+ im.grab = nil
416+ im.server.grabEnded()
417+ }
418+ }
419+ s := im.server
420+ if s.keymapFD >= 0 {
421+ res.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
422+ }
423+ res.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
424+ s.serial++
425+ m := s.modState
426+ res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
427+ s.log.Debug().Msg("input method grabbed keyboard")
428+}
429+
430+func (*inputMethod) Destroy(*inputmethod.ZwpInputMethodV2) {}
431+
432+// inputPopup is an input method's candidate surface.
433+type inputPopup struct {
434+ im *inputMethod
435+ res *inputmethod.ZwpInputPopupSurfaceV2
436+}
437+
438+func (*inputPopup) Destroy(*inputmethod.ZwpInputPopupSurfaceV2) {}
439+
440+// sendRect tells the popup where the active text cursor is.
441+func (p *inputPopup) sendRect() {
442+ t := p.im.server.activeText
443+ if t == nil || !t.current.hasRect {
444+ return
445+ }
446+ r := t.current.rect
447+ p.res.SendTextInputRectangle(int32(r.X), int32(r.Y), int32(r.W), int32(r.H))
448+}
449+
450+type keyboardGrab struct {
451+ res *inputmethod.ZwpInputMethodKeyboardGrabV2
452+}
453+
454+func (*keyboardGrab) Release(*inputmethod.ZwpInputMethodKeyboardGrabV2) {}
455+
456+// grab is the live keyboard grab, nil without one.
457+func (s *Server) grab() *keyboardGrab {
458+ if im := s.inputMethod; im != nil {
459+ return im.grab
460+ }
461+ return nil
462+}
463+
464+// grabKey sends a focused key to the input method grab and reports
465+// whether it consumed it. The release of a key the client got pressed
466+// goes to the client; the release of a key the grab got pressed never
467+// does, even after the grab ends.
468+func (s *Server) grabKey(c ports.ForwardKey) bool {
469+ code := c.Key.Keycode
470+ g := s.grab()
471+ owned := !c.Key.Pressed && s.grabKeys[code]
472+ if owned {
473+ delete(s.grabKeys, code)
474+ if g == nil {
475+ s.grabReleaseDropped(c.Key.State)
476+ return true
477+ }
478+ } else if g == nil || c.ID != s.focused || !c.Key.Pressed && s.heldKeys[code] {
479+ return false
480+ }
481+ state := uint32(0)
482+ if c.Key.Pressed {
483+ state = 1
484+ if s.grabKeys == nil {
485+ s.grabKeys = make(map[uint32]bool)
486+ }
487+ s.grabKeys[code] = true
488+ }
489+ s.serial++
490+ g.res.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state)
491+ if c.Key.State != s.modState {
492+ s.modState = c.Key.State
493+ m := s.modState
494+ s.serial++
495+ g.res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
496+ }
497+ return true
498+}
499+
500+// grabReleaseDropped keeps the focused client's modifiers right when the
501+// release of a grab-owned key is dropped.
502+func (s *Server) grabReleaseDropped(m ports.ModState) {
503+ if m == s.modState {
504+ return
505+ }
506+ s.modState = m
507+ s.serial++
508+ for _, k := range s.clientKeyboards(s.focusClient()) {
509+ s.sendModifiers(k)
510+ }
511+}
512+
513+// grabEnded gives the focused client the seat modifiers it missed.
514+func (s *Server) grabEnded() {
515+ s.serial++
516+ for _, k := range s.clientKeyboards(s.focusClient()) {
517+ s.sendModifiers(k)
518+ }
519+ s.log.Debug().Msg("input method keyboard grab ended")
520+}
521+
522+// sendGrabKeymap updates a grab after a seat keymap or repeat change.
523+func (s *Server) sendGrabKeymap(keymap bool) {
524+ g := s.grab()
525+ if g == nil {
526+ return
527+ }
528+ if keymap && s.keymapFD >= 0 {
529+ g.res.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
530+ }
531+ g.res.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
532+}
@@ -0,0 +1,532 @@
1+package wayland
2+
3+import (
4+ "github.com/bnema/neferwl/internal/ports"
5+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
6+ "github.com/bnema/purego-libwayland/protocol/textinput"
7+ "github.com/bnema/purego-libwayland/protocol/wayland"
8+ "github.com/bnema/purego-libwayland/server"
9+)
10+
11+// Text input (zwp_text_input_v3) lets clients take IME text; one input
12+// method (zwp_input_method_v2, e.g. fcitx5 or IBus) composes it. Text
13+// input focus follows keyboard focus. The first focused text input that
14+// commits enable is the active one: the input method serves only it.
15+
16+// maxIMEText is the spec limit for strings carried by both protocols.
17+const maxIMEText = 4000
18+
19+// textState is the double-buffered state of a text input.
20+type textState struct {
21+ enabled bool
22+ surrounding string
23+ cursor, anchor uint32
24+ hasSurrounding bool
25+ cause uint32
26+ hint, purpose uint32
27+ rect ports.Rect
28+ hasRect bool
29+}
30+
31+type textInput struct {
32+ server *Server
33+ res *textinput.ZwpTextInputV3
34+ client server.Client
35+ // focus is the entered surface, nil after leave.
36+ focus *wayland.Surface
37+ pending, current textState
38+ // toggled: an enable or disable request is pending.
39+ toggled bool
40+ // serial counts commit requests, the serial of done events.
41+ serial uint32
42+}
43+
44+func registerTextInput(d *server.Display, s *Server) error {
45+ return textinput.NewZwpTextInputManagerV3Global(d, 1, func(c server.Client, v, id uint32) {
46+ _, _ = textinput.NewZwpTextInputManagerV3(c, int32(v), id, textInputManager{s})
47+ })
48+}
49+
50+type textInputManager struct{ server *Server }
51+
52+func (textInputManager) Destroy(*textinput.ZwpTextInputManagerV3) {}
53+
54+func (m textInputManager) GetTextInput(r *textinput.ZwpTextInputManagerV3, id uint32, _ *wayland.Seat) {
55+ s := m.server
56+ t := &textInput{server: s, client: r.Client()}
57+ res, err := textinput.NewZwpTextInputV3(r.Client(), r.Version(), id, t)
58+ if err != nil {
59+ return
60+ }
61+ t.res = res
62+ s.textInputs = append(s.textInputs, t)
63+ res.OnDestroy = func() {
64+ s.textInputs = removeItem(s.textInputs, t)
65+ if s.activeText == t {
66+ s.deactivateText()
67+ }
68+ t.focus = nil
69+ }
70+ if surf, _ := s.focusTarget(s.focused); surf != nil && surf.Client() == t.client {
71+ t.enter(surf)
72+ }
73+}
74+
75+func (*textInput) Destroy(*textinput.ZwpTextInputV3) {}
76+
77+// Enable resets the pending state, as the spec asks.
78+func (t *textInput) Enable(*textinput.ZwpTextInputV3) {
79+ t.pending, t.toggled = textState{enabled: true}, true
80+}
81+
82+// Disable invalidates the pending state.
83+func (t *textInput) Disable(*textinput.ZwpTextInputV3) {
84+ t.pending, t.toggled = textState{}, true
85+}
86+
87+// SetSurroundingText ignores text past the spec limit or a cursor or
88+// anchor outside it.
89+func (t *textInput) SetSurroundingText(_ *textinput.ZwpTextInputV3, text string, cursor, anchor int32) {
90+ if len(text) > maxIMEText || cursor < 0 || anchor < 0 || int(cursor) > len(text) || int(anchor) > len(text) {
91+ t.server.log.Debug().Int("bytes", len(text)).Msg("surrounding text ignored")
92+ return
93+ }
94+ p := &t.pending
95+ p.surrounding, p.cursor, p.anchor, p.hasSurrounding = text, uint32(cursor), uint32(anchor), true
96+}
97+
98+func (t *textInput) SetTextChangeCause(_ *textinput.ZwpTextInputV3, cause uint32) {
99+ t.pending.cause = cause
100+}
101+
102+func (t *textInput) SetContentType(_ *textinput.ZwpTextInputV3, hint, purpose uint32) {
103+ t.pending.hint, t.pending.purpose = hint, purpose
104+}
105+
106+func (t *textInput) SetCursorRectangle(_ *textinput.ZwpTextInputV3, x, y, w, h int32) {
107+ t.pending.rect, t.pending.hasRect = ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, true
108+}
109+
110+// Commit applies the pending state. Every commit counts for the done
111+// serial, but after leave the state is ignored until the next enter.
112+func (t *textInput) Commit(*textinput.ZwpTextInputV3) {
113+ t.serial++
114+ if t.focus == nil {
115+ return
116+ }
117+ reenabled := t.toggled && t.pending.enabled
118+ t.current = t.pending
119+ t.pending.cause = uint32(textinput.ZwpTextInputV3ChangeCauseInputMethod)
120+ t.toggled = false
121+ t.server.textCommitted(t, reenabled)
122+}
123+
124+// Version 2 requests: the global is version 1.
125+func (*textInput) SetAvailableActions(*textinput.ZwpTextInputV3, []byte) {}
126+func (*textInput) ShowInputPanel(*textinput.ZwpTextInputV3) {}
127+func (*textInput) HideInputPanel(*textinput.ZwpTextInputV3) {}
128+
129+// enter focuses the text input on surf; its state starts over.
130+func (t *textInput) enter(surf *wayland.Surface) {
131+ t.focus = surf
132+ t.pending, t.current, t.toggled = textState{}, textState{}, false
133+ t.res.SendEnter(surf)
134+}
135+
136+// leave clears the focus; a destroyed surface gets no leave event.
137+func (t *textInput) leave() {
138+ s := t.server
139+ if s.activeText == t {
140+ s.deactivateText()
141+ }
142+ if t.focus.Resource.Alive() {
143+ t.res.SendLeave(t.focus)
144+ }
145+ t.focus = nil
146+ t.pending, t.current, t.toggled = textState{}, textState{}, false
147+}
148+
149+// textCommitted activates, updates or deactivates the input method for a
150+// committed text input. Enabling a second text input is ignored.
151+func (s *Server) textCommitted(t *textInput, reenabled bool) {
152+ switch {
153+ case t.current.enabled && s.activeText == nil:
154+ s.activeText = t
155+ s.activateIM()
156+ case t.current.enabled && s.activeText == t:
157+ if reenabled {
158+ s.activateIM()
159+ } else {
160+ s.sendIMState()
161+ }
162+ case !t.current.enabled && s.activeText == t:
163+ s.deactivateText()
164+ }
165+}
166+
167+// textInputFocus moves text input focus to the keyboard focus: leave for
168+// text inputs on another surface, then enter for those of the focused
169+// client, as the spec orders them.
170+func (s *Server) textInputFocus() {
171+ surf, _ := s.focusTarget(s.focused)
172+ for _, t := range s.textInputs {
173+ if t.focus != nil && (surf == nil || t.focus.Resource != surf.Resource) {
174+ t.leave()
175+ }
176+ }
177+ if surf == nil {
178+ return
179+ }
180+ for _, t := range s.textInputs {
181+ if t.focus == nil && t.client == surf.Client() {
182+ t.enter(surf)
183+ }
184+ }
185+}
186+
187+// deactivateText drops the active text input and tells the input method.
188+func (s *Server) deactivateText() {
189+ s.activeText = nil
190+ im := s.inputMethod
191+ if im == nil {
192+ return
193+ }
194+ im.res.SendDeactivate()
195+ im.done()
196+}
197+
198+// activateIM sends activate and the active text input's state.
199+func (s *Server) activateIM() {
200+ im := s.inputMethod
201+ if im == nil || s.activeText == nil {
202+ return
203+ }
204+ im.pending = imState{}
205+ im.activated = im.dones
206+ im.res.SendActivate()
207+ s.sendIMState()
208+}
209+
210+// sendIMState sends the active text input's state, then done.
211+func (s *Server) sendIMState() {
212+ im, t := s.inputMethod, s.activeText
213+ if im == nil || t == nil {
214+ return
215+ }
216+ c := t.current
217+ if c.hasSurrounding {
218+ im.res.SendSurroundingText(c.surrounding, c.cursor, c.anchor)
219+ }
220+ im.res.SendTextChangeCause(c.cause)
221+ im.res.SendContentType(c.hint, c.purpose)
222+ for _, p := range im.popups {
223+ p.sendRect()
224+ }
225+ im.done()
226+}
227+
228+// imState is the double-buffered state of the input method.
229+type imState struct {
230+ preedit string
231+ begin, end int32
232+ hasPreedit bool
233+ commit string
234+ hasCommit bool
235+ before, after uint32
236+}
237+
238+type inputMethod struct {
239+ server *Server
240+ res *inputmethod.ZwpInputMethodV2
241+ // inert: another input method was bound first; requests are ignored.
242+ inert bool
243+ dones uint32
244+ // activated is dones when the last activate was sent: a commit with
245+ // a serial up to it predates the activation.
246+ activated uint32
247+ pending imState
248+ grab *keyboardGrab
249+ popups []*inputPopup
250+}
251+
252+func registerInputMethod(d *server.Display, s *Server) error {
253+ return inputmethod.NewZwpInputMethodManagerV2Global(d, 1, func(c server.Client, v, id uint32) {
254+ _, _ = inputmethod.NewZwpInputMethodManagerV2(c, int32(v), id, inputMethodManager{s})
255+ })
256+}
257+
258+type inputMethodManager struct{ server *Server }
259+
260+func (inputMethodManager) Destroy(*inputmethod.ZwpInputMethodManagerV2) {}
261+
262+// GetInputMethod makes the first input method the seat's; later ones get
263+// unavailable until it is gone.
264+func (m inputMethodManager) GetInputMethod(r *inputmethod.ZwpInputMethodManagerV2, _ *wayland.Seat, id uint32) {
265+ s := m.server
266+ im := &inputMethod{server: s, inert: s.inputMethod != nil}
267+ res, err := inputmethod.NewZwpInputMethodV2(r.Client(), r.Version(), id, im)
268+ if err != nil {
269+ return
270+ }
271+ im.res = res
272+ if im.inert {
273+ res.SendUnavailable()
274+ s.log.Debug().Int("pid", r.Client().PID()).Msg("input method unavailable")
275+ return
276+ }
277+ s.inputMethod = im
278+ res.OnDestroy = func() { s.inputMethodGone(im) }
279+ s.log.Info().Int("pid", r.Client().PID()).Msg("input method bound")
280+ s.activateIM()
281+}
282+
283+// inputMethodGone frees the seat slot. A grab ends; the active text
284+// input's preedit is cleared by an empty done.
285+func (s *Server) inputMethodGone(im *inputMethod) {
286+ if s.inputMethod != im {
287+ return
288+ }
289+ s.inputMethod = nil
290+ if im.grab != nil {
291+ im.grab = nil
292+ s.grabEnded()
293+ }
294+ if t := s.activeText; t != nil {
295+ t.res.SendDone(t.serial)
296+ }
297+ s.log.Info().Msg("input method gone")
298+}
299+
300+// done sends done and counts it for commit serials.
301+func (im *inputMethod) done() {
302+ im.res.SendDone()
303+ im.dones++
304+}
305+
306+func (im *inputMethod) CommitString(_ *inputmethod.ZwpInputMethodV2, text string) {
307+ if im.inert || !im.fits(text) {
308+ return
309+ }
310+ im.pending.commit, im.pending.hasCommit = text, true
311+}
312+
313+func (im *inputMethod) SetPreeditString(_ *inputmethod.ZwpInputMethodV2, text string, begin, end int32) {
314+ if im.inert || !im.fits(text) {
315+ return
316+ }
317+ p := &im.pending
318+ p.preedit, p.begin, p.end, p.hasPreedit = text, begin, end, true
319+}
320+
321+func (im *inputMethod) fits(text string) bool {
322+ if len(text) > maxIMEText {
323+ im.server.log.Debug().Int("bytes", len(text)).Msg("input method text too long")
324+ return false
325+ }
326+ return true
327+}
328+
329+func (im *inputMethod) DeleteSurroundingText(_ *inputmethod.ZwpInputMethodV2, before, after uint32) {
330+ if im.inert {
331+ return
332+ }
333+ im.pending.before, im.pending.after = before, after
334+}
335+
336+// Commit sends the pending state to the active text input, in the
337+// spec's order, with done carrying the text input's commit count.
338+// A commit from before the last activate is dropped: its text belongs to
339+// the previous text input. Within one activation, like wlroots, a stale
340+// serial is only logged: dropping the commit would lose typed text.
341+func (im *inputMethod) Commit(_ *inputmethod.ZwpInputMethodV2, serial uint32) {
342+ if im.inert {
343+ return
344+ }
345+ st := im.pending
346+ im.pending = imState{}
347+ s := im.server
348+ t := s.activeText
349+ if t == nil {
350+ return
351+ }
352+ if serial <= im.activated {
353+ s.log.Debug().Uint32("serial", serial).Uint32("activated", im.activated).Msg("input method commit before activation dropped")
354+ return
355+ }
356+ if serial != im.dones {
357+ s.log.Debug().Uint32("serial", serial).Uint32("done", im.dones).Msg("input method commit with stale serial")
358+ }
359+ if st.hasPreedit {
360+ t.res.SendPreeditString(st.preedit, st.begin, st.end)
361+ }
362+ if st.hasCommit {
363+ t.res.SendCommitString(st.commit)
364+ }
365+ if st.before != 0 || st.after != 0 {
366+ t.res.SendDeleteSurroundingText(st.before, st.after)
367+ }
368+ t.res.SendDone(t.serial)
369+}
370+
371+// GetInputPopupSurface gives a surface the input_popup role. The popup
372+// learns the text cursor rectangle; it is not drawn.
373+func (im *inputMethod) GetInputPopupSurface(r *inputmethod.ZwpInputMethodV2, id uint32, surf *wayland.Surface) {
374+ s := im.server
375+ if im.inert {
376+ // An unavailable input method's requests have no effect.
377+ _, _ = inputmethod.NewZwpInputPopupSurfaceV2(r.Client(), r.Version(), id, &inputPopup{im: im})
378+ return
379+ }
380+ if surf == nil {
381+ return
382+ }
383+ state := s.surfaces[surf.Resource]
384+ if state == nil {
385+ return
386+ }
387+ if state.kind != roleNone {
388+ r.PostError(uint32(inputmethod.ZwpInputMethodV2ErrorRole), "surface already has a role")
389+ return
390+ }
391+ p := &inputPopup{im: im}
392+ res, err := inputmethod.NewZwpInputPopupSurfaceV2(r.Client(), r.Version(), id, p)
393+ if err != nil {
394+ return
395+ }
396+ state.kind, p.res = roleInputPopup, res
397+ im.popups = append(im.popups, p)
398+ res.OnDestroy = func() { im.popups = removeItem(im.popups, p) }
399+ if s.activeText != nil {
400+ p.sendRect()
401+ }
402+}
403+
404+// GrabKeyboard routes seat keys to the input method. A second grab
405+// while one is held stays inert.
406+func (im *inputMethod) GrabKeyboard(r *inputmethod.ZwpInputMethodV2, id uint32) {
407+ g := &keyboardGrab{}
408+ res, err := inputmethod.NewZwpInputMethodKeyboardGrabV2(r.Client(), r.Version(), id, g)
409+ if err != nil || im.inert || im.grab != nil {
410+ return
411+ }
412+ g.res, im.grab = res, g
413+ res.OnDestroy = func() {
414+ if im.grab == g {
415+ im.grab = nil
416+ im.server.grabEnded()
417+ }
418+ }
419+ s := im.server
420+ if s.keymapFD >= 0 {
421+ res.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
422+ }
423+ res.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
424+ s.serial++
425+ m := s.modState
426+ res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
427+ s.log.Debug().Msg("input method grabbed keyboard")
428+}
429+
430+func (*inputMethod) Destroy(*inputmethod.ZwpInputMethodV2) {}
431+
432+// inputPopup is an input method's candidate surface.
433+type inputPopup struct {
434+ im *inputMethod
435+ res *inputmethod.ZwpInputPopupSurfaceV2
436+}
437+
438+func (*inputPopup) Destroy(*inputmethod.ZwpInputPopupSurfaceV2) {}
439+
440+// sendRect tells the popup where the active text cursor is.
441+func (p *inputPopup) sendRect() {
442+ t := p.im.server.activeText
443+ if t == nil || !t.current.hasRect {
444+ return
445+ }
446+ r := t.current.rect
447+ p.res.SendTextInputRectangle(int32(r.X), int32(r.Y), int32(r.W), int32(r.H))
448+}
449+
450+type keyboardGrab struct {
451+ res *inputmethod.ZwpInputMethodKeyboardGrabV2
452+}
453+
454+func (*keyboardGrab) Release(*inputmethod.ZwpInputMethodKeyboardGrabV2) {}
455+
456+// grab is the live keyboard grab, nil without one.
457+func (s *Server) grab() *keyboardGrab {
458+ if im := s.inputMethod; im != nil {
459+ return im.grab
460+ }
461+ return nil
462+}
463+
464+// grabKey sends a focused key to the input method grab and reports
465+// whether it consumed it. The release of a key the client got pressed
466+// goes to the client; the release of a key the grab got pressed never
467+// does, even after the grab ends.
468+func (s *Server) grabKey(c ports.ForwardKey) bool {
469+ code := c.Key.Keycode
470+ g := s.grab()
471+ owned := !c.Key.Pressed && s.grabKeys[code]
472+ if owned {
473+ delete(s.grabKeys, code)
474+ if g == nil {
475+ s.grabReleaseDropped(c.Key.State)
476+ return true
477+ }
478+ } else if g == nil || c.ID != s.focused || !c.Key.Pressed && s.heldKeys[code] {
479+ return false
480+ }
481+ state := uint32(0)
482+ if c.Key.Pressed {
483+ state = 1
484+ if s.grabKeys == nil {
485+ s.grabKeys = make(map[uint32]bool)
486+ }
487+ s.grabKeys[code] = true
488+ }
489+ s.serial++
490+ g.res.SendKey(s.serial, c.Key.TimeMsec, c.Key.Keycode, state)
491+ if c.Key.State != s.modState {
492+ s.modState = c.Key.State
493+ m := s.modState
494+ s.serial++
495+ g.res.SendModifiers(s.serial, m.Depressed, m.Latched, m.Locked, m.Group)
496+ }
497+ return true
498+}
499+
500+// grabReleaseDropped keeps the focused client's modifiers right when the
501+// release of a grab-owned key is dropped.
502+func (s *Server) grabReleaseDropped(m ports.ModState) {
503+ if m == s.modState {
504+ return
505+ }
506+ s.modState = m
507+ s.serial++
508+ for _, k := range s.clientKeyboards(s.focusClient()) {
509+ s.sendModifiers(k)
510+ }
511+}
512+
513+// grabEnded gives the focused client the seat modifiers it missed.
514+func (s *Server) grabEnded() {
515+ s.serial++
516+ for _, k := range s.clientKeyboards(s.focusClient()) {
517+ s.sendModifiers(k)
518+ }
519+ s.log.Debug().Msg("input method keyboard grab ended")
520+}
521+
522+// sendGrabKeymap updates a grab after a seat keymap or repeat change.
523+func (s *Server) sendGrabKeymap(keymap bool) {
524+ g := s.grab()
525+ if g == nil {
526+ return
527+ }
528+ if keymap && s.keymapFD >= 0 {
529+ g.res.SendKeymap(uint32(wayland.KeyboardKeymapFormatXkbV1), s.keymapFD, s.keymapSize)
530+ }
531+ g.res.SendRepeatInfo(int32(s.repeatRate), int32(s.repeatDelay))
532+}
A internal/adapters/wayland/textinput_test.go +518 -0
@@ -0,0 +1,518 @@
1+package wayland
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
12+ "github.com/bnema/purego-libwayland/protocol/textinput"
13+ "github.com/bnema/purego-libwayland/protocol/wayland"
14+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
15+ "github.com/bnema/wlturbo"
16+ "golang.org/x/sys/unix"
17+)
18+
19+// imeLog records events of one object as "name arg arg" strings, from
20+// the event signatures: u, i, o (IDs), s (strings) and h (fds, closed).
21+type imeLog struct {
22+ wlturbo.BaseProxy
23+ events []string
24+ sigs []struct{ name, args string }
25+}
26+
27+func (l *imeLog) Dispatch(e *wlturbo.Event) {
28+ if int(e.Opcode) >= len(l.sigs) {
29+ l.events = append(l.events, fmt.Sprint("unknown ", e.Opcode))
30+ return
31+ }
32+ sig := l.sigs[e.Opcode]
33+ parts := []string{sig.name}
34+ for _, a := range sig.args {
35+ switch a {
36+ case 'u', 'o':
37+ parts = append(parts, fmt.Sprint(e.Uint32()))
38+ case 'i':
39+ parts = append(parts, fmt.Sprint(e.Int32()))
40+ case 's':
41+ parts = append(parts, fmt.Sprintf("%q", e.String()))
42+ case 'h':
43+ if fd := int(e.Fd()); fd > 0 {
44+ unix.Close(fd)
45+ }
46+ parts = append(parts, "fd")
47+ }
48+ }
49+ l.events = append(l.events, strings.Join(parts, " "))
50+}
51+
52+// take returns the recorded events and forgets them.
53+func (l *imeLog) take() []string {
54+ ev := l.events
55+ l.events = nil
56+ return ev
57+}
58+
59+func newIMELog(c *wlturbo.Display, id uint32, sigs ...string) *imeLog {
60+ l := &imeLog{}
61+ for _, s := range sigs {
62+ name, args, _ := strings.Cut(s, ":")
63+ l.sigs = append(l.sigs, struct{ name, args string }{name, args})
64+ }
65+ l.SetID(id)
66+ c.Context().Register(l)
67+ return l
68+}
69+
70+var (
71+ textInputEvents = []string{"enter:o", "leave:o", "preedit_string:sii", "commit_string:s", "delete_surrounding_text:uu", "done:u"}
72+ inputMethodEvents = []string{"activate", "deactivate", "surrounding_text:suu", "text_change_cause:u", "content_type:uu", "done", "unavailable"}
73+ grabEvents = []string{"keymap:uhu", "key:uuuu", "modifiers:uuuuu", "repeat_info:ii"}
74+)
75+
76+// textApp is a focused client window with a text input.
77+type textApp struct {
78+ c *wlturbo.Display
79+ ti, surf uint32
80+ top uint32
81+ window ports.WindowID
82+ log *imeLog
83+ keys *eventsProxy
84+}
85+
86+func newTextApp(t *testing.T, s *Server, events chan ports.ClientEvent, commands chan ports.ClientCommand, dir string) *textApp {
87+ t.Helper()
88+ c := protocolClient(t, s, dir)
89+ seat := bindProtocol(t, c, "wl_seat")
90+ registerProtocol(t, c, seat)
91+ keys := &eventsProxy{}
92+ keys.SetID(c.AllocateID())
93+ c.Context().Register(keys)
94+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
95+ w, surf, xdg := surfaceMapper(t, c, events)()
96+ manager := bindProtocol(t, c, "zwp_text_input_manager_v3")
97+ registerProtocol(t, c, manager)
98+ ti := c.AllocateID()
99+ log := newIMELog(c, ti, textInputEvents...)
100+ requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, ti, seat)
101+ commands <- ports.FocusWindow{ID: w.ID}
102+ waitFocus(t, s, w.ID)
103+ roundtrip(t, c)
104+ // surfaceMapper allocated the toplevel right after the xdg_surface.
105+ return &textApp{c: c, ti: ti, surf: surf, top: xdg + 1, window: w.ID, log: log, keys: keys}
106+}
107+
108+func (a *textApp) request(t *testing.T, op uint32, args ...any) {
109+ t.Helper()
110+ requestProtocol(t, a.c, a.ti, op, args...)
111+}
112+
113+// enable commits an enabled text input with surrounding text and content type.
114+func (a *textApp) enable(t *testing.T) {
115+ t.Helper()
116+ a.request(t, textinput.ZwpTextInputV3RequestEnable)
117+ a.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "hello", int32(5), int32(5))
118+ a.request(t, textinput.ZwpTextInputV3RequestSetContentType, uint32(textinput.ZwpTextInputV3ContentHintSpellcheck), uint32(textinput.ZwpTextInputV3ContentPurposeNormal))
119+ a.request(t, textinput.ZwpTextInputV3RequestCommit)
120+}
121+
122+// imeApp is an input method client.
123+type imeApp struct {
124+ c *wlturbo.Display
125+ im uint32
126+ log *imeLog
127+}
128+
129+func newIMEApp(t *testing.T, s *Server, dir string) *imeApp {
130+ t.Helper()
131+ c := protocolClient(t, s, dir)
132+ seat := bindProtocol(t, c, "wl_seat")
133+ registerProtocol(t, c, seat)
134+ manager := bindProtocol(t, c, "zwp_input_method_manager_v2")
135+ registerProtocol(t, c, manager)
136+ im := c.AllocateID()
137+ log := newIMELog(c, im, inputMethodEvents...)
138+ requestProtocol(t, c, manager, inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod, seat, im)
139+ roundtrip(t, c)
140+ return &imeApp{c: c, im: im, log: log}
141+}
142+
143+func (m *imeApp) request(t *testing.T, op uint32, args ...any) {
144+ t.Helper()
145+ requestProtocol(t, m.c, m.im, op, args...)
146+}
147+
148+func (m *imeApp) grab(t *testing.T) (uint32, *imeLog) {
149+ t.Helper()
150+ g := m.c.AllocateID()
151+ log := newIMELog(m.c, g, grabEvents...)
152+ m.request(t, inputmethod.ZwpInputMethodV2RequestGrabKeyboard, g)
153+ return g, log
154+}
155+
156+// settle lets the server handle every connection's requests, in order.
157+func settle(t *testing.T, cs ...*wlturbo.Display) {
158+ t.Helper()
159+ roundtrip(t, cs...)
160+ roundtrip(t, cs...)
161+}
162+
163+func expectEvents(t *testing.T, what string, got []string, want ...string) {
164+ t.Helper()
165+ if !slices.Equal(got, want) {
166+ t.Fatalf("%s events:\n got %q\nwant %q", what, got, want)
167+ }
168+}
169+
170+// Text input focus follows keyboard focus across clients and window close.
171+func TestTextInputFollowsFocus(t *testing.T) {
172+ s, events, commands, dir := keyboardServer(t)
173+ a := newTextApp(t, s, events, commands, dir)
174+ expectEvents(t, "a", a.log.take(), fmt.Sprint("enter ", a.surf))
175+ b := newTextApp(t, s, events, commands, dir)
176+ settle(t, a.c, b.c)
177+ expectEvents(t, "a", a.log.take(), fmt.Sprint("leave ", a.surf))
178+ expectEvents(t, "b", b.log.take(), fmt.Sprint("enter ", b.surf))
179+
180+ commands <- ports.FocusWindow{ID: a.window}
181+ waitFocus(t, s, a.window)
182+ settle(t, a.c, b.c)
183+ expectEvents(t, "a", a.log.take(), fmt.Sprint("enter ", a.surf))
184+ expectEvents(t, "b", b.log.take(), fmt.Sprint("leave ", b.surf))
185+
186+ // Closing the focused window leaves.
187+ requestProtocol(t, a.c, a.top, xdgshell.ToplevelRequestDestroy)
188+ settle(t, a.c)
189+ waitFocus(t, s, 0)
190+ settle(t, a.c)
191+ expectEvents(t, "a", a.log.take(), fmt.Sprint("leave ", a.surf))
192+}
193+
194+// Enable activates the input method with the client state; its preedit,
195+// commit and delete reach the client in order with the commit count.
196+func TestTextInputIMERoundTrip(t *testing.T) {
197+ s, events, commands, dir := keyboardServer(t)
198+ ime := newIMEApp(t, s, dir)
199+ app := newTextApp(t, s, events, commands, dir)
200+ app.log.take()
201+ app.enable(t)
202+ settle(t, app.c, ime.c)
203+ expectEvents(t, "ime", ime.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
204+
205+ ime.request(t, inputmethod.ZwpInputMethodV2RequestSetPreeditString, "nihon", int32(5), int32(5))
206+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
207+ settle(t, ime.c, app.c)
208+ expectEvents(t, "app", app.log.take(), `preedit_string "nihon" 5 5`, "done 1")
209+
210+ ime.request(t, inputmethod.ZwpInputMethodV2RequestDeleteSurroundingText, uint32(1), uint32(0))
211+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "日本")
212+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
213+ settle(t, ime.c, app.c)
214+ expectEvents(t, "app", app.log.take(), `commit_string "日本"`, "delete_surrounding_text 1 0", "done 1")
215+
216+ // State updates reach the input method; done carries the latest
217+ // client commit count even when the input method is behind.
218+ app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "hello日本", int32(11), int32(11))
219+ app.request(t, textinput.ZwpTextInputV3RequestSetTextChangeCause, uint32(textinput.ZwpTextInputV3ChangeCauseOther))
220+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
221+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
222+ settle(t, app.c, ime.c)
223+ expectEvents(t, "ime", ime.log.take(), `surrounding_text "hello日本" 11 11`, "text_change_cause 1", "content_type 2 0", "done", `surrounding_text "hello日本" 11 11`, "text_change_cause 0", "content_type 2 0", "done")
224+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "x")
225+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
226+ settle(t, ime.c, app.c)
227+ expectEvents(t, "app", app.log.take(), `commit_string "x"`, "done 3")
228+
229+ app.request(t, textinput.ZwpTextInputV3RequestDisable)
230+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
231+ settle(t, app.c, ime.c)
232+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
233+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "lost")
234+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(4))
235+ settle(t, ime.c, app.c)
236+ expectEvents(t, "app", app.log.take())
237+}
238+
239+// Focus loss deactivates; a second text input cannot take over; state
240+// past the spec limit is ignored.
241+func TestTextInputFocusLossAndLimits(t *testing.T) {
242+ s, events, commands, dir := keyboardServer(t)
243+ ime := newIMEApp(t, s, dir)
244+ app := newTextApp(t, s, events, commands, dir)
245+ app.enable(t)
246+ settle(t, app.c, ime.c)
247+ ime.log.take()
248+
249+ app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, strings.Repeat("a", maxIMEText+1), int32(0), int32(0))
250+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
251+ settle(t, app.c, ime.c)
252+ expectEvents(t, "ime", ime.log.take(), `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
253+
254+ other := newTextApp(t, s, events, commands, dir)
255+ settle(t, app.c, ime.c)
256+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
257+ // After leave, app's requests are ignored until the next enter.
258+ app.enable(t)
259+ settle(t, app.c, ime.c)
260+ expectEvents(t, "ime", ime.log.take())
261+ other.enable(t)
262+ settle(t, other.c, ime.c)
263+ expectEvents(t, "ime", ime.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
264+}
265+
266+// Only one input method at a time; the slot frees when it disconnects,
267+// and the next one is activated for the enabled text input.
268+func TestInputMethodSingleSlot(t *testing.T) {
269+ s, events, commands, dir := keyboardServer(t)
270+ first := newIMEApp(t, s, dir)
271+ second := newIMEApp(t, s, dir)
272+ expectEvents(t, "second", second.log.take(), "unavailable")
273+ // An inert input method's requests do nothing.
274+ second.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "x")
275+ second.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(0))
276+ app := newTextApp(t, s, events, commands, dir)
277+ app.enable(t)
278+ settle(t, second.c, app.c, first.c)
279+ app.log.take()
280+ _ = first.c.Close()
281+ deadline := time.Now().Add(2 * time.Second)
282+ for len(app.log.events) == 0 && time.Now().Before(deadline) {
283+ roundtrip(t, app.c)
284+ time.Sleep(5 * time.Millisecond)
285+ }
286+ // The preedit is cleared by an empty done.
287+ expectEvents(t, "app", app.log.take(), "done 1")
288+ third := newIMEApp(t, s, dir)
289+ expectEvents(t, "third", third.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
290+}
291+
292+// until roundtrips cs until ok holds: commands reach the display
293+// goroutine on their own channel, not in order with requests.
294+func until(t *testing.T, what string, ok func() bool, cs ...*wlturbo.Display) {
295+ t.Helper()
296+ for deadline := time.Now().Add(2 * time.Second); !ok(); time.Sleep(5 * time.Millisecond) {
297+ if time.Now().After(deadline) {
298+ t.Fatal(what)
299+ }
300+ roundtrip(t, cs...)
301+ }
302+}
303+
304+// A keyboard grab takes the focused client's keys until released; a key
305+// the client saw pressed is released to the client.
306+func TestInputMethodKeyboardGrab(t *testing.T) {
307+ s, events, commands, dir := keyboardServer(t)
308+ app := newTextApp(t, s, events, commands, dir)
309+ ime := newIMEApp(t, s, dir)
310+ app.keys.opcodes = nil
311+ key := func(code uint32, pressed bool) {
312+ commands <- ports.ForwardKey{ID: app.window, Key: ports.KeyEvent{Keycode: code, Pressed: pressed, TimeMsec: 7}}
313+ }
314+ k := uint16(wayland.KeyboardEventKey)
315+ key(31, true) // held by the client before the grab
316+ until(t, "client did not get the key before the grab", func() bool { return len(app.keys.opcodes) == 1 }, app.c)
317+ g, grab := ime.grab(t)
318+ settle(t, ime.c)
319+ got := grab.take()
320+ if len(got) != 3 || got[0] != "keymap 1 fd "+fmt.Sprint(len(keymapText(t))+1) || got[1] != "repeat_info 25 600" || !strings.HasPrefix(got[2], "modifiers ") {
321+ t.Fatalf("grab setup %q", got)
322+ }
323+ key(30, true)
324+ key(30, false)
325+ key(31, false)
326+ until(t, "client did not get the release of 31", func() bool { return len(app.keys.opcodes) == 2 }, app.c)
327+ settle(t, ime.c)
328+ if len(grab.events) != 2 || !strings.HasSuffix(grab.events[0], " 7 30 1") || !strings.HasSuffix(grab.events[1], " 7 30 0") {
329+ t.Fatalf("grab keys %q", grab.events)
330+ }
331+ if !slices.Equal(app.keys.opcodes, []uint16{k, k}) {
332+ t.Fatalf("client keys %v, want the press and release of 31", app.keys.opcodes)
333+ }
334+ // 33 is pressed through the grab and released after it ends: the
335+ // client never sees it.
336+ key(33, true)
337+ until(t, "grab did not get 33", func() bool { return len(grab.events) == 3 }, ime.c)
338+ requestProtocol(t, ime.c, g, inputmethod.ZwpInputMethodKeyboardGrabV2RequestRelease)
339+ settle(t, ime.c)
340+ app.keys.opcodes = nil
341+ key(33, false)
342+ key(32, true)
343+ until(t, "key after release did not reach the client", func() bool { return slices.Contains(app.keys.opcodes, k) }, app.c)
344+ settle(t, ime.c)
345+ if len(grab.events) != 3 {
346+ t.Fatalf("released grab got %q", grab.events)
347+ }
348+ keys := 0
349+ for _, op := range app.keys.opcodes {
350+ if op == k {
351+ keys++
352+ }
353+ }
354+ if keys != 1 {
355+ t.Fatalf("client keys %v, want only the press of 32", app.keys.opcodes)
356+ }
357+}
358+
359+// Disconnects and destroyed surfaces in active states do not break the
360+// seat: keys return to the client, the input method is deactivated.
361+func TestTextInputTeardown(t *testing.T) {
362+ s, events, commands, dir := keyboardServer(t)
363+ ime := newIMEApp(t, s, dir)
364+ app := newTextApp(t, s, events, commands, dir)
365+ app.enable(t)
366+ ime.grab(t)
367+ settle(t, app.c, ime.c)
368+ ime.log.take()
369+
370+ // The focused surface dies with an active text input.
371+ requestProtocol(t, app.c, app.top, xdgshell.ToplevelRequestDestroy)
372+ requestProtocol(t, app.c, app.surf, wayland.SurfaceRequestDestroy)
373+ settle(t, app.c, ime.c)
374+ unmapped(t, events, app.window)
375+ waitFocus(t, s, 0)
376+ settle(t, ime.c)
377+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
378+
379+ other := newTextApp(t, s, events, commands, dir)
380+ other.enable(t)
381+ settle(t, other.c, ime.c)
382+ ime.log.take()
383+ _ = other.c.Close()
384+ deadline := time.Now().Add(2 * time.Second)
385+ for len(ime.log.events) == 0 && time.Now().Before(deadline) {
386+ roundtrip(t, ime.c)
387+ time.Sleep(5 * time.Millisecond)
388+ }
389+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
390+ unmapped(t, events, other.window)
391+
392+ // With the grabbing input method gone, keys reach the client again.
393+ last := newTextApp(t, s, events, commands, dir)
394+ _ = ime.c.Close()
395+ last.keys.opcodes = nil
396+ deadline = time.Now().Add(2 * time.Second)
397+ for !slices.Contains(last.keys.opcodes, uint16(wayland.KeyboardEventKey)) && time.Now().Before(deadline) {
398+ commands <- ports.ForwardKey{ID: last.window, Key: ports.KeyEvent{Keycode: 30, Pressed: true}}
399+ commands <- ports.ForwardKey{ID: last.window, Key: ports.KeyEvent{Keycode: 30}}
400+ roundtrip(t, last.c)
401+ time.Sleep(10 * time.Millisecond)
402+ }
403+ if !slices.Contains(last.keys.opcodes, uint16(wayland.KeyboardEventKey)) {
404+ t.Fatal("keys did not return to the client")
405+ }
406+}
407+
408+// An input popup gets the text cursor rectangle; a surface with a role
409+// cannot become one.
410+func TestInputPopupSurface(t *testing.T) {
411+ s, events, commands, dir := keyboardServer(t)
412+ ime := newIMEApp(t, s, dir)
413+ app := newTextApp(t, s, events, commands, dir)
414+ comp := bindProtocol(t, ime.c, "wl_compositor")
415+ surf, popup := ime.c.AllocateID(), ime.c.AllocateID()
416+ registerProtocol(t, ime.c, surf)
417+ requestProtocol(t, ime.c, comp, wayland.CompositorRequestCreateSurface, surf)
418+ rects := newIMELog(ime.c, popup, "text_input_rectangle:iiii")
419+ ime.request(t, inputmethod.ZwpInputMethodV2RequestGetInputPopupSurface, popup, surf)
420+ app.request(t, textinput.ZwpTextInputV3RequestEnable)
421+ app.request(t, textinput.ZwpTextInputV3RequestSetCursorRectangle, int32(10), int32(20), int32(2), int32(16))
422+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
423+ settle(t, ime.c, app.c, ime.c)
424+ expectEvents(t, "popup", rects.take(), "text_input_rectangle 10 20 2 16")
425+
426+ again := ime.c.AllocateID()
427+ registerProtocol(t, ime.c, again)
428+ ime.request(t, inputmethod.ZwpInputMethodV2RequestGetInputPopupSurface, again, surf)
429+ expectProtocolError(t, ime.c, ime.im, uint32(inputmethod.ZwpInputMethodV2ErrorRole))
430+}
431+
432+// tagged forwards a text input's events to a shared log, prefixed, so
433+// the order across objects shows.
434+type tagged struct {
435+ wlturbo.BaseProxy
436+ tag string
437+ into *imeLog
438+}
439+
440+func (p *tagged) Dispatch(e *wlturbo.Event) {
441+ p.into.Dispatch(e)
442+ last := &p.into.events[len(p.into.events)-1]
443+ *last = p.tag + " " + *last
444+}
445+
446+// On a focus change every leave comes before any enter, whatever the
447+// order the text inputs were created in.
448+func TestTextInputLeaveBeforeEnter(t *testing.T) {
449+ s, events, commands, dir := keyboardServer(t)
450+ c := protocolClient(t, s, dir)
451+ seat := bindProtocol(t, c, "wl_seat")
452+ registerProtocol(t, c, seat)
453+ mapWindow := surfaceMapper(t, c, events)
454+ a, aSurf, _ := mapWindow()
455+ b, bSurf, _ := mapWindow()
456+ manager := bindProtocol(t, c, "zwp_text_input_manager_v3")
457+ registerProtocol(t, c, manager)
458+ log := newIMELog(c, 0, textInputEvents...)
459+ for _, tag := range []string{"t1", "t2"} {
460+ p := &tagged{tag: tag, into: log}
461+ p.SetID(c.AllocateID())
462+ c.Context().Register(p)
463+ requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat)
464+ }
465+ commands <- ports.FocusWindow{ID: a.ID}
466+ waitFocus(t, s, a.ID)
467+ settle(t, c)
468+ log.take()
469+ commands <- ports.FocusWindow{ID: b.ID}
470+ waitFocus(t, s, b.ID)
471+ settle(t, c)
472+ expectEvents(t, "focus", log.take(), fmt.Sprint("t1 leave ", aSurf), fmt.Sprint("t2 leave ", aSurf), fmt.Sprint("t1 enter ", bSurf), fmt.Sprint("t2 enter ", bSurf))
473+}
474+
475+// A commit sent for a previous activation does not reach the next text
476+// input; one for the current activation does.
477+func TestInputMethodCommitBeforeActivationDropped(t *testing.T) {
478+ s, events, commands, dir := keyboardServer(t)
479+ ime := newIMEApp(t, s, dir)
480+ first := newTextApp(t, s, events, commands, dir)
481+ first.enable(t)
482+ settle(t, first.c, ime.c)
483+ expectEvents(t, "ime", ime.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
484+ second := newTextApp(t, s, events, commands, dir)
485+ second.log.take()
486+ second.enable(t)
487+ settle(t, second.c, ime.c)
488+ if got := ime.log.take(); len(got) != 7 || got[0] != "deactivate" || got[2] != "activate" {
489+ t.Fatalf("ime events %q", got)
490+ }
491+ // Serial 1 is first's activation; second's is 3.
492+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "late")
493+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
494+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "ok")
495+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(3))
496+ settle(t, ime.c, second.c)
497+ expectEvents(t, "second", second.log.take(), `commit_string "ok"`, "done 1")
498+}
499+
500+// An unavailable input method's popup request has no effect: the surface
501+// keeps no role.
502+func TestInertInputMethodPopupIgnored(t *testing.T) {
503+ s, _, _, dir := keyboardServer(t)
504+ newIMEApp(t, s, dir)
505+ inert := newIMEApp(t, s, dir)
506+ inert.log.take()
507+ comp := bindProtocol(t, inert.c, "wl_compositor")
508+ surf := inert.c.AllocateID()
509+ registerProtocol(t, inert.c, surf)
510+ requestProtocol(t, inert.c, comp, wayland.CompositorRequestCreateSurface, surf)
511+ for range 2 {
512+ popup := inert.c.AllocateID()
513+ registerProtocol(t, inert.c, popup)
514+ inert.request(t, inputmethod.ZwpInputMethodV2RequestGetInputPopupSurface, popup, surf)
515+ }
516+ settle(t, inert.c)
517+ expectEvents(t, "inert", inert.log.take())
518+}
@@ -0,0 +1,518 @@
1+package wayland
2+
3+import (
4+ "fmt"
5+ "slices"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/purego-libwayland/protocol/inputmethod"
12+ "github.com/bnema/purego-libwayland/protocol/textinput"
13+ "github.com/bnema/purego-libwayland/protocol/wayland"
14+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
15+ "github.com/bnema/wlturbo"
16+ "golang.org/x/sys/unix"
17+)
18+
19+// imeLog records events of one object as "name arg arg" strings, from
20+// the event signatures: u, i, o (IDs), s (strings) and h (fds, closed).
21+type imeLog struct {
22+ wlturbo.BaseProxy
23+ events []string
24+ sigs []struct{ name, args string }
25+}
26+
27+func (l *imeLog) Dispatch(e *wlturbo.Event) {
28+ if int(e.Opcode) >= len(l.sigs) {
29+ l.events = append(l.events, fmt.Sprint("unknown ", e.Opcode))
30+ return
31+ }
32+ sig := l.sigs[e.Opcode]
33+ parts := []string{sig.name}
34+ for _, a := range sig.args {
35+ switch a {
36+ case 'u', 'o':
37+ parts = append(parts, fmt.Sprint(e.Uint32()))
38+ case 'i':
39+ parts = append(parts, fmt.Sprint(e.Int32()))
40+ case 's':
41+ parts = append(parts, fmt.Sprintf("%q", e.String()))
42+ case 'h':
43+ if fd := int(e.Fd()); fd > 0 {
44+ unix.Close(fd)
45+ }
46+ parts = append(parts, "fd")
47+ }
48+ }
49+ l.events = append(l.events, strings.Join(parts, " "))
50+}
51+
52+// take returns the recorded events and forgets them.
53+func (l *imeLog) take() []string {
54+ ev := l.events
55+ l.events = nil
56+ return ev
57+}
58+
59+func newIMELog(c *wlturbo.Display, id uint32, sigs ...string) *imeLog {
60+ l := &imeLog{}
61+ for _, s := range sigs {
62+ name, args, _ := strings.Cut(s, ":")
63+ l.sigs = append(l.sigs, struct{ name, args string }{name, args})
64+ }
65+ l.SetID(id)
66+ c.Context().Register(l)
67+ return l
68+}
69+
70+var (
71+ textInputEvents = []string{"enter:o", "leave:o", "preedit_string:sii", "commit_string:s", "delete_surrounding_text:uu", "done:u"}
72+ inputMethodEvents = []string{"activate", "deactivate", "surrounding_text:suu", "text_change_cause:u", "content_type:uu", "done", "unavailable"}
73+ grabEvents = []string{"keymap:uhu", "key:uuuu", "modifiers:uuuuu", "repeat_info:ii"}
74+)
75+
76+// textApp is a focused client window with a text input.
77+type textApp struct {
78+ c *wlturbo.Display
79+ ti, surf uint32
80+ top uint32
81+ window ports.WindowID
82+ log *imeLog
83+ keys *eventsProxy
84+}
85+
86+func newTextApp(t *testing.T, s *Server, events chan ports.ClientEvent, commands chan ports.ClientCommand, dir string) *textApp {
87+ t.Helper()
88+ c := protocolClient(t, s, dir)
89+ seat := bindProtocol(t, c, "wl_seat")
90+ registerProtocol(t, c, seat)
91+ keys := &eventsProxy{}
92+ keys.SetID(c.AllocateID())
93+ c.Context().Register(keys)
94+ requestProtocol(t, c, seat, wayland.SeatRequestGetKeyboard, keys.ID())
95+ w, surf, xdg := surfaceMapper(t, c, events)()
96+ manager := bindProtocol(t, c, "zwp_text_input_manager_v3")
97+ registerProtocol(t, c, manager)
98+ ti := c.AllocateID()
99+ log := newIMELog(c, ti, textInputEvents...)
100+ requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, ti, seat)
101+ commands <- ports.FocusWindow{ID: w.ID}
102+ waitFocus(t, s, w.ID)
103+ roundtrip(t, c)
104+ // surfaceMapper allocated the toplevel right after the xdg_surface.
105+ return &textApp{c: c, ti: ti, surf: surf, top: xdg + 1, window: w.ID, log: log, keys: keys}
106+}
107+
108+func (a *textApp) request(t *testing.T, op uint32, args ...any) {
109+ t.Helper()
110+ requestProtocol(t, a.c, a.ti, op, args...)
111+}
112+
113+// enable commits an enabled text input with surrounding text and content type.
114+func (a *textApp) enable(t *testing.T) {
115+ t.Helper()
116+ a.request(t, textinput.ZwpTextInputV3RequestEnable)
117+ a.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "hello", int32(5), int32(5))
118+ a.request(t, textinput.ZwpTextInputV3RequestSetContentType, uint32(textinput.ZwpTextInputV3ContentHintSpellcheck), uint32(textinput.ZwpTextInputV3ContentPurposeNormal))
119+ a.request(t, textinput.ZwpTextInputV3RequestCommit)
120+}
121+
122+// imeApp is an input method client.
123+type imeApp struct {
124+ c *wlturbo.Display
125+ im uint32
126+ log *imeLog
127+}
128+
129+func newIMEApp(t *testing.T, s *Server, dir string) *imeApp {
130+ t.Helper()
131+ c := protocolClient(t, s, dir)
132+ seat := bindProtocol(t, c, "wl_seat")
133+ registerProtocol(t, c, seat)
134+ manager := bindProtocol(t, c, "zwp_input_method_manager_v2")
135+ registerProtocol(t, c, manager)
136+ im := c.AllocateID()
137+ log := newIMELog(c, im, inputMethodEvents...)
138+ requestProtocol(t, c, manager, inputmethod.ZwpInputMethodManagerV2RequestGetInputMethod, seat, im)
139+ roundtrip(t, c)
140+ return &imeApp{c: c, im: im, log: log}
141+}
142+
143+func (m *imeApp) request(t *testing.T, op uint32, args ...any) {
144+ t.Helper()
145+ requestProtocol(t, m.c, m.im, op, args...)
146+}
147+
148+func (m *imeApp) grab(t *testing.T) (uint32, *imeLog) {
149+ t.Helper()
150+ g := m.c.AllocateID()
151+ log := newIMELog(m.c, g, grabEvents...)
152+ m.request(t, inputmethod.ZwpInputMethodV2RequestGrabKeyboard, g)
153+ return g, log
154+}
155+
156+// settle lets the server handle every connection's requests, in order.
157+func settle(t *testing.T, cs ...*wlturbo.Display) {
158+ t.Helper()
159+ roundtrip(t, cs...)
160+ roundtrip(t, cs...)
161+}
162+
163+func expectEvents(t *testing.T, what string, got []string, want ...string) {
164+ t.Helper()
165+ if !slices.Equal(got, want) {
166+ t.Fatalf("%s events:\n got %q\nwant %q", what, got, want)
167+ }
168+}
169+
170+// Text input focus follows keyboard focus across clients and window close.
171+func TestTextInputFollowsFocus(t *testing.T) {
172+ s, events, commands, dir := keyboardServer(t)
173+ a := newTextApp(t, s, events, commands, dir)
174+ expectEvents(t, "a", a.log.take(), fmt.Sprint("enter ", a.surf))
175+ b := newTextApp(t, s, events, commands, dir)
176+ settle(t, a.c, b.c)
177+ expectEvents(t, "a", a.log.take(), fmt.Sprint("leave ", a.surf))
178+ expectEvents(t, "b", b.log.take(), fmt.Sprint("enter ", b.surf))
179+
180+ commands <- ports.FocusWindow{ID: a.window}
181+ waitFocus(t, s, a.window)
182+ settle(t, a.c, b.c)
183+ expectEvents(t, "a", a.log.take(), fmt.Sprint("enter ", a.surf))
184+ expectEvents(t, "b", b.log.take(), fmt.Sprint("leave ", b.surf))
185+
186+ // Closing the focused window leaves.
187+ requestProtocol(t, a.c, a.top, xdgshell.ToplevelRequestDestroy)
188+ settle(t, a.c)
189+ waitFocus(t, s, 0)
190+ settle(t, a.c)
191+ expectEvents(t, "a", a.log.take(), fmt.Sprint("leave ", a.surf))
192+}
193+
194+// Enable activates the input method with the client state; its preedit,
195+// commit and delete reach the client in order with the commit count.
196+func TestTextInputIMERoundTrip(t *testing.T) {
197+ s, events, commands, dir := keyboardServer(t)
198+ ime := newIMEApp(t, s, dir)
199+ app := newTextApp(t, s, events, commands, dir)
200+ app.log.take()
201+ app.enable(t)
202+ settle(t, app.c, ime.c)
203+ expectEvents(t, "ime", ime.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
204+
205+ ime.request(t, inputmethod.ZwpInputMethodV2RequestSetPreeditString, "nihon", int32(5), int32(5))
206+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
207+ settle(t, ime.c, app.c)
208+ expectEvents(t, "app", app.log.take(), `preedit_string "nihon" 5 5`, "done 1")
209+
210+ ime.request(t, inputmethod.ZwpInputMethodV2RequestDeleteSurroundingText, uint32(1), uint32(0))
211+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "日本")
212+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
213+ settle(t, ime.c, app.c)
214+ expectEvents(t, "app", app.log.take(), `commit_string "日本"`, "delete_surrounding_text 1 0", "done 1")
215+
216+ // State updates reach the input method; done carries the latest
217+ // client commit count even when the input method is behind.
218+ app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, "hello日本", int32(11), int32(11))
219+ app.request(t, textinput.ZwpTextInputV3RequestSetTextChangeCause, uint32(textinput.ZwpTextInputV3ChangeCauseOther))
220+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
221+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
222+ settle(t, app.c, ime.c)
223+ expectEvents(t, "ime", ime.log.take(), `surrounding_text "hello日本" 11 11`, "text_change_cause 1", "content_type 2 0", "done", `surrounding_text "hello日本" 11 11`, "text_change_cause 0", "content_type 2 0", "done")
224+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "x")
225+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
226+ settle(t, ime.c, app.c)
227+ expectEvents(t, "app", app.log.take(), `commit_string "x"`, "done 3")
228+
229+ app.request(t, textinput.ZwpTextInputV3RequestDisable)
230+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
231+ settle(t, app.c, ime.c)
232+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
233+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "lost")
234+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(4))
235+ settle(t, ime.c, app.c)
236+ expectEvents(t, "app", app.log.take())
237+}
238+
239+// Focus loss deactivates; a second text input cannot take over; state
240+// past the spec limit is ignored.
241+func TestTextInputFocusLossAndLimits(t *testing.T) {
242+ s, events, commands, dir := keyboardServer(t)
243+ ime := newIMEApp(t, s, dir)
244+ app := newTextApp(t, s, events, commands, dir)
245+ app.enable(t)
246+ settle(t, app.c, ime.c)
247+ ime.log.take()
248+
249+ app.request(t, textinput.ZwpTextInputV3RequestSetSurroundingText, strings.Repeat("a", maxIMEText+1), int32(0), int32(0))
250+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
251+ settle(t, app.c, ime.c)
252+ expectEvents(t, "ime", ime.log.take(), `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
253+
254+ other := newTextApp(t, s, events, commands, dir)
255+ settle(t, app.c, ime.c)
256+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
257+ // After leave, app's requests are ignored until the next enter.
258+ app.enable(t)
259+ settle(t, app.c, ime.c)
260+ expectEvents(t, "ime", ime.log.take())
261+ other.enable(t)
262+ settle(t, other.c, ime.c)
263+ expectEvents(t, "ime", ime.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
264+}
265+
266+// Only one input method at a time; the slot frees when it disconnects,
267+// and the next one is activated for the enabled text input.
268+func TestInputMethodSingleSlot(t *testing.T) {
269+ s, events, commands, dir := keyboardServer(t)
270+ first := newIMEApp(t, s, dir)
271+ second := newIMEApp(t, s, dir)
272+ expectEvents(t, "second", second.log.take(), "unavailable")
273+ // An inert input method's requests do nothing.
274+ second.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "x")
275+ second.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(0))
276+ app := newTextApp(t, s, events, commands, dir)
277+ app.enable(t)
278+ settle(t, second.c, app.c, first.c)
279+ app.log.take()
280+ _ = first.c.Close()
281+ deadline := time.Now().Add(2 * time.Second)
282+ for len(app.log.events) == 0 && time.Now().Before(deadline) {
283+ roundtrip(t, app.c)
284+ time.Sleep(5 * time.Millisecond)
285+ }
286+ // The preedit is cleared by an empty done.
287+ expectEvents(t, "app", app.log.take(), "done 1")
288+ third := newIMEApp(t, s, dir)
289+ expectEvents(t, "third", third.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
290+}
291+
292+// until roundtrips cs until ok holds: commands reach the display
293+// goroutine on their own channel, not in order with requests.
294+func until(t *testing.T, what string, ok func() bool, cs ...*wlturbo.Display) {
295+ t.Helper()
296+ for deadline := time.Now().Add(2 * time.Second); !ok(); time.Sleep(5 * time.Millisecond) {
297+ if time.Now().After(deadline) {
298+ t.Fatal(what)
299+ }
300+ roundtrip(t, cs...)
301+ }
302+}
303+
304+// A keyboard grab takes the focused client's keys until released; a key
305+// the client saw pressed is released to the client.
306+func TestInputMethodKeyboardGrab(t *testing.T) {
307+ s, events, commands, dir := keyboardServer(t)
308+ app := newTextApp(t, s, events, commands, dir)
309+ ime := newIMEApp(t, s, dir)
310+ app.keys.opcodes = nil
311+ key := func(code uint32, pressed bool) {
312+ commands <- ports.ForwardKey{ID: app.window, Key: ports.KeyEvent{Keycode: code, Pressed: pressed, TimeMsec: 7}}
313+ }
314+ k := uint16(wayland.KeyboardEventKey)
315+ key(31, true) // held by the client before the grab
316+ until(t, "client did not get the key before the grab", func() bool { return len(app.keys.opcodes) == 1 }, app.c)
317+ g, grab := ime.grab(t)
318+ settle(t, ime.c)
319+ got := grab.take()
320+ if len(got) != 3 || got[0] != "keymap 1 fd "+fmt.Sprint(len(keymapText(t))+1) || got[1] != "repeat_info 25 600" || !strings.HasPrefix(got[2], "modifiers ") {
321+ t.Fatalf("grab setup %q", got)
322+ }
323+ key(30, true)
324+ key(30, false)
325+ key(31, false)
326+ until(t, "client did not get the release of 31", func() bool { return len(app.keys.opcodes) == 2 }, app.c)
327+ settle(t, ime.c)
328+ if len(grab.events) != 2 || !strings.HasSuffix(grab.events[0], " 7 30 1") || !strings.HasSuffix(grab.events[1], " 7 30 0") {
329+ t.Fatalf("grab keys %q", grab.events)
330+ }
331+ if !slices.Equal(app.keys.opcodes, []uint16{k, k}) {
332+ t.Fatalf("client keys %v, want the press and release of 31", app.keys.opcodes)
333+ }
334+ // 33 is pressed through the grab and released after it ends: the
335+ // client never sees it.
336+ key(33, true)
337+ until(t, "grab did not get 33", func() bool { return len(grab.events) == 3 }, ime.c)
338+ requestProtocol(t, ime.c, g, inputmethod.ZwpInputMethodKeyboardGrabV2RequestRelease)
339+ settle(t, ime.c)
340+ app.keys.opcodes = nil
341+ key(33, false)
342+ key(32, true)
343+ until(t, "key after release did not reach the client", func() bool { return slices.Contains(app.keys.opcodes, k) }, app.c)
344+ settle(t, ime.c)
345+ if len(grab.events) != 3 {
346+ t.Fatalf("released grab got %q", grab.events)
347+ }
348+ keys := 0
349+ for _, op := range app.keys.opcodes {
350+ if op == k {
351+ keys++
352+ }
353+ }
354+ if keys != 1 {
355+ t.Fatalf("client keys %v, want only the press of 32", app.keys.opcodes)
356+ }
357+}
358+
359+// Disconnects and destroyed surfaces in active states do not break the
360+// seat: keys return to the client, the input method is deactivated.
361+func TestTextInputTeardown(t *testing.T) {
362+ s, events, commands, dir := keyboardServer(t)
363+ ime := newIMEApp(t, s, dir)
364+ app := newTextApp(t, s, events, commands, dir)
365+ app.enable(t)
366+ ime.grab(t)
367+ settle(t, app.c, ime.c)
368+ ime.log.take()
369+
370+ // The focused surface dies with an active text input.
371+ requestProtocol(t, app.c, app.top, xdgshell.ToplevelRequestDestroy)
372+ requestProtocol(t, app.c, app.surf, wayland.SurfaceRequestDestroy)
373+ settle(t, app.c, ime.c)
374+ unmapped(t, events, app.window)
375+ waitFocus(t, s, 0)
376+ settle(t, ime.c)
377+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
378+
379+ other := newTextApp(t, s, events, commands, dir)
380+ other.enable(t)
381+ settle(t, other.c, ime.c)
382+ ime.log.take()
383+ _ = other.c.Close()
384+ deadline := time.Now().Add(2 * time.Second)
385+ for len(ime.log.events) == 0 && time.Now().Before(deadline) {
386+ roundtrip(t, ime.c)
387+ time.Sleep(5 * time.Millisecond)
388+ }
389+ expectEvents(t, "ime", ime.log.take(), "deactivate", "done")
390+ unmapped(t, events, other.window)
391+
392+ // With the grabbing input method gone, keys reach the client again.
393+ last := newTextApp(t, s, events, commands, dir)
394+ _ = ime.c.Close()
395+ last.keys.opcodes = nil
396+ deadline = time.Now().Add(2 * time.Second)
397+ for !slices.Contains(last.keys.opcodes, uint16(wayland.KeyboardEventKey)) && time.Now().Before(deadline) {
398+ commands <- ports.ForwardKey{ID: last.window, Key: ports.KeyEvent{Keycode: 30, Pressed: true}}
399+ commands <- ports.ForwardKey{ID: last.window, Key: ports.KeyEvent{Keycode: 30}}
400+ roundtrip(t, last.c)
401+ time.Sleep(10 * time.Millisecond)
402+ }
403+ if !slices.Contains(last.keys.opcodes, uint16(wayland.KeyboardEventKey)) {
404+ t.Fatal("keys did not return to the client")
405+ }
406+}
407+
408+// An input popup gets the text cursor rectangle; a surface with a role
409+// cannot become one.
410+func TestInputPopupSurface(t *testing.T) {
411+ s, events, commands, dir := keyboardServer(t)
412+ ime := newIMEApp(t, s, dir)
413+ app := newTextApp(t, s, events, commands, dir)
414+ comp := bindProtocol(t, ime.c, "wl_compositor")
415+ surf, popup := ime.c.AllocateID(), ime.c.AllocateID()
416+ registerProtocol(t, ime.c, surf)
417+ requestProtocol(t, ime.c, comp, wayland.CompositorRequestCreateSurface, surf)
418+ rects := newIMELog(ime.c, popup, "text_input_rectangle:iiii")
419+ ime.request(t, inputmethod.ZwpInputMethodV2RequestGetInputPopupSurface, popup, surf)
420+ app.request(t, textinput.ZwpTextInputV3RequestEnable)
421+ app.request(t, textinput.ZwpTextInputV3RequestSetCursorRectangle, int32(10), int32(20), int32(2), int32(16))
422+ app.request(t, textinput.ZwpTextInputV3RequestCommit)
423+ settle(t, ime.c, app.c, ime.c)
424+ expectEvents(t, "popup", rects.take(), "text_input_rectangle 10 20 2 16")
425+
426+ again := ime.c.AllocateID()
427+ registerProtocol(t, ime.c, again)
428+ ime.request(t, inputmethod.ZwpInputMethodV2RequestGetInputPopupSurface, again, surf)
429+ expectProtocolError(t, ime.c, ime.im, uint32(inputmethod.ZwpInputMethodV2ErrorRole))
430+}
431+
432+// tagged forwards a text input's events to a shared log, prefixed, so
433+// the order across objects shows.
434+type tagged struct {
435+ wlturbo.BaseProxy
436+ tag string
437+ into *imeLog
438+}
439+
440+func (p *tagged) Dispatch(e *wlturbo.Event) {
441+ p.into.Dispatch(e)
442+ last := &p.into.events[len(p.into.events)-1]
443+ *last = p.tag + " " + *last
444+}
445+
446+// On a focus change every leave comes before any enter, whatever the
447+// order the text inputs were created in.
448+func TestTextInputLeaveBeforeEnter(t *testing.T) {
449+ s, events, commands, dir := keyboardServer(t)
450+ c := protocolClient(t, s, dir)
451+ seat := bindProtocol(t, c, "wl_seat")
452+ registerProtocol(t, c, seat)
453+ mapWindow := surfaceMapper(t, c, events)
454+ a, aSurf, _ := mapWindow()
455+ b, bSurf, _ := mapWindow()
456+ manager := bindProtocol(t, c, "zwp_text_input_manager_v3")
457+ registerProtocol(t, c, manager)
458+ log := newIMELog(c, 0, textInputEvents...)
459+ for _, tag := range []string{"t1", "t2"} {
460+ p := &tagged{tag: tag, into: log}
461+ p.SetID(c.AllocateID())
462+ c.Context().Register(p)
463+ requestProtocol(t, c, manager, textinput.ZwpTextInputManagerV3RequestGetTextInput, p.ID(), seat)
464+ }
465+ commands <- ports.FocusWindow{ID: a.ID}
466+ waitFocus(t, s, a.ID)
467+ settle(t, c)
468+ log.take()
469+ commands <- ports.FocusWindow{ID: b.ID}
470+ waitFocus(t, s, b.ID)
471+ settle(t, c)
472+ expectEvents(t, "focus", log.take(), fmt.Sprint("t1 leave ", aSurf), fmt.Sprint("t2 leave ", aSurf), fmt.Sprint("t1 enter ", bSurf), fmt.Sprint("t2 enter ", bSurf))
473+}
474+
475+// A commit sent for a previous activation does not reach the next text
476+// input; one for the current activation does.
477+func TestInputMethodCommitBeforeActivationDropped(t *testing.T) {
478+ s, events, commands, dir := keyboardServer(t)
479+ ime := newIMEApp(t, s, dir)
480+ first := newTextApp(t, s, events, commands, dir)
481+ first.enable(t)
482+ settle(t, first.c, ime.c)
483+ expectEvents(t, "ime", ime.log.take(), "activate", `surrounding_text "hello" 5 5`, "text_change_cause 0", "content_type 2 0", "done")
484+ second := newTextApp(t, s, events, commands, dir)
485+ second.log.take()
486+ second.enable(t)
487+ settle(t, second.c, ime.c)
488+ if got := ime.log.take(); len(got) != 7 || got[0] != "deactivate" || got[2] != "activate" {
489+ t.Fatalf("ime events %q", got)
490+ }
491+ // Serial 1 is first's activation; second's is 3.
492+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "late")
493+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(1))
494+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommitString, "ok")
495+ ime.request(t, inputmethod.ZwpInputMethodV2RequestCommit, uint32(3))
496+ settle(t, ime.c, second.c)
497+ expectEvents(t, "second", second.log.take(), `commit_string "ok"`, "done 1")
498+}
499+
500+// An unavailable input method's popup request has no effect: the surface
501+// keeps no role.
502+func TestInertInputMethodPopupIgnored(t *testing.T) {
503+ s, _, _, dir := keyboardServer(t)
504+ newIMEApp(t, s, dir)
505+ inert := newIMEApp(t, s, dir)
506+ inert.log.take()
507+ comp := bindProtocol(t, inert.c, "wl_compositor")
508+ surf := inert.c.AllocateID()
509+ registerProtocol(t, inert.c, surf)
510+ requestProtocol(t, inert.c, comp, wayland.CompositorRequestCreateSurface, surf)
511+ for range 2 {
512+ popup := inert.c.AllocateID()
513+ registerProtocol(t, inert.c, popup)
514+ inert.request(t, inputmethod.ZwpInputMethodV2RequestGetInputPopupSurface, popup, surf)
515+ }
516+ settle(t, inert.c)
517+ expectEvents(t, "inert", inert.log.take())
518+}
A internal/app/textinput_test.go +285 -0
@@ -0,0 +1,285 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/adapters/config"
15+ "github.com/bnema/purego-libwayland/protocol/textinput"
16+ "github.com/bnema/purego-libwayland/protocol/wayland"
17+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+// lockedBuffer is an io.Writer read while exec writes to it.
23+type lockedBuffer struct {
24+ mu sync.Mutex
25+ buf bytes.Buffer
26+}
27+
28+func (b *lockedBuffer) Write(p []byte) (int, error) {
29+ b.mu.Lock()
30+ defer b.mu.Unlock()
31+ return b.buf.Write(p)
32+}
33+
34+func (b *lockedBuffer) String() string {
35+ b.mu.Lock()
36+ defer b.mu.Unlock()
37+ return b.buf.String()
38+}
39+
40+// textClient records what a text input receives.
41+type textClient struct {
42+ wlturbo.BaseProxy
43+ entered bool
44+ preedit []string
45+ commits []string
46+}
47+
48+func (p *textClient) Dispatch(e *wlturbo.Event) {
49+ switch uint32(e.Opcode) {
50+ case textinput.ZwpTextInputV3EventEnter:
51+ p.entered = true
52+ case textinput.ZwpTextInputV3EventPreeditString:
53+ p.preedit = append(p.preedit, e.String())
54+ case textinput.ZwpTextInputV3EventCommitString:
55+ p.commits = append(p.commits, e.String())
56+ }
57+}
58+
59+// xdgConfigure records the last configure serial.
60+type xdgConfigure struct {
61+ wlturbo.BaseProxy
62+ serial uint32
63+}
64+
65+func (p *xdgConfigure) Dispatch(e *wlturbo.Event) {
66+ if uint32(e.Opcode) == xdgshell.SurfaceEventConfigure {
67+ p.serial = e.Uint32()
68+ }
69+}
70+
71+// quiet ignores the events of objects the test does not inspect.
72+type quiet struct{ wlturbo.BaseProxy }
73+
74+func (*quiet) Dispatch(*wlturbo.Event) {}
75+
76+type wmPing struct {
77+ wlturbo.BaseProxy
78+ c *wlturbo.Display
79+}
80+
81+func (p *wmPing) Dispatch(e *wlturbo.Event) {
82+ if uint32(e.Opcode) == xdgshell.WmBaseEventPing {
83+ _ = p.c.SendRequest(p.ID(), uint16(xdgshell.WmBaseRequestPong), e.Uint32())
84+ }
85+}
86+
87+// NeferWL headless, the examples/testime input method and a text input
88+// client: the input method's preedit and commit reach the client.
89+func TestHeadlessTextInputIME(t *testing.T) {
90+ runtime := t.TempDir()
91+ t.Setenv("XDG_RUNTIME_DIR", runtime)
92+ t.Setenv("XDG_STATE_HOME", t.TempDir())
93+ ime := filepath.Join(t.TempDir(), "testime")
94+ build := exec.Command("go", "build", "-o", ime, "../../examples/testime")
95+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
96+ if out, err := build.CombinedOutput(); err != nil {
97+ t.Fatalf("build testime: %v: %s", err, out)
98+ }
99+
100+ ctx, cancel := context.WithCancel(context.Background())
101+ done := make(chan error, 1)
102+ go func() {
103+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults()})
104+ }()
105+ stopped := false // Run's result was already received
106+ defer func() {
107+ cancel()
108+ if stopped {
109+ return
110+ }
111+ select {
112+ case err := <-done:
113+ if err != nil {
114+ t.Errorf("Run: %v", err)
115+ }
116+ case <-time.After(5 * time.Second):
117+ t.Error("Run did not stop")
118+ }
119+ }()
120+ socket := filepath.Join(runtime, "wayland-1")
121+ for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {
122+ if _, err := os.Stat(socket); err == nil {
123+ break
124+ }
125+ select {
126+ case err := <-done:
127+ stopped = true
128+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
129+ t.Skipf("Vulkan unavailable: %v", err)
130+ }
131+ t.Fatalf("neferwl exited: %v", err)
132+ default:
133+ }
134+ if time.Now().After(deadline) {
135+ t.Fatal("no wayland socket")
136+ }
137+ }
138+
139+ // Two commits: the second comes from enabling the text input again.
140+ cmd := exec.Command(ime, "-count", "2", "-preedit", "nihon", "-commit", "日本")
141+ cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY=wayland-1")
142+ imeOut := &lockedBuffer{}
143+ cmd.Stderr = imeOut
144+ if err := cmd.Start(); err != nil {
145+ t.Fatal(err)
146+ }
147+ imeDone := make(chan error, 1)
148+ go func() { imeDone <- cmd.Wait() }()
149+ imeExited := false
150+ defer func() {
151+ if !imeExited {
152+ _ = cmd.Process.Kill()
153+ <-imeDone
154+ }
155+ }()
156+ for deadline := time.Now().Add(5 * time.Second); !strings.Contains(imeOut.String(), "ready"); time.Sleep(10 * time.Millisecond) {
157+ if time.Now().After(deadline) {
158+ t.Fatalf("testime not ready: %s", imeOut.String())
159+ }
160+ }
161+
162+ c, err := wlturbo.Connect(socket)
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ defer c.Close()
167+ text := mapTextClient(t, c)
168+ poll := func(what string, ok func() bool) {
169+ t.Helper()
170+ for deadline := time.Now().Add(5 * time.Second); !ok(); time.Sleep(10 * time.Millisecond) {
171+ if time.Now().After(deadline) {
172+ t.Fatalf("%s: preedit %q commits %q, testime: %s", what, text.preedit, text.commits, imeOut.String())
173+ }
174+ if err := c.Roundtrip(); err != nil {
175+ t.Fatal(err)
176+ }
177+ }
178+ }
179+ poll("no text input enter", func() bool { return text.entered })
180+ send := func(op uint32, args ...any) {
181+ t.Helper()
182+ if err := c.SendRequest(text.ID(), uint16(op), args...); err != nil {
183+ t.Fatal(err)
184+ }
185+ }
186+ for i := 1; i <= 2; i++ {
187+ send(textinput.ZwpTextInputV3RequestEnable)
188+ send(textinput.ZwpTextInputV3RequestSetContentType, uint32(0), uint32(0))
189+ send(textinput.ZwpTextInputV3RequestCommit)
190+ poll("missing commit", func() bool { return len(text.commits) == i })
191+ }
192+ if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" {
193+ t.Fatalf("preedit %q commits %q", text.preedit, text.commits)
194+ }
195+ select {
196+ case err := <-imeDone:
197+ imeExited = true
198+ if err != nil {
199+ t.Fatalf("testime: %v: %s", err, imeOut.String())
200+ }
201+ case <-time.After(5 * time.Second):
202+ t.Fatalf("testime did not exit after two commits: %s", imeOut.String())
203+ }
204+}
205+
206+// mapTextClient maps a 1x1 toplevel with a text input on c.
207+func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient {
208+ t.Helper()
209+ if err := c.Roundtrip(); err != nil {
210+ t.Fatal(err)
211+ }
212+ bind := func(iface string) uint32 {
213+ t.Helper()
214+ g, ok := c.Registry().FindGlobal(iface)
215+ if !ok {
216+ t.Fatalf("missing %s", iface)
217+ }
218+ id, err := c.Registry().BindID(g.Name, g.Interface, 1)
219+ if err != nil {
220+ t.Fatal(err)
221+ }
222+ return id
223+ }
224+ // New IDs are used in allocation order; every object gets a proxy.
225+ newID := func() uint32 {
226+ p := &quiet{}
227+ p.SetID(c.AllocateID())
228+ c.Context().Register(p)
229+ return p.ID()
230+ }
231+ req := func(id, op uint32, args ...any) {
232+ t.Helper()
233+ if err := c.SendRequest(id, uint16(op), args...); err != nil {
234+ t.Fatal(err)
235+ }
236+ }
237+ comp, shm, seat, tim := bind("wl_compositor"), bind("wl_shm"), bind("wl_seat"), bind("zwp_text_input_manager_v3")
238+ for _, id := range []uint32{comp, shm, seat, tim} {
239+ p := &quiet{}
240+ p.SetID(id)
241+ c.Context().Register(p)
242+ }
243+ wm := &wmPing{c: c}
244+ wm.SetID(bind("xdg_wm_base"))
245+ c.Context().Register(wm)
246+ fd, err := unix.MemfdCreate("text-client", 0)
247+ if err != nil {
248+ t.Fatal(err)
249+ }
250+ defer unix.Close(fd)
251+ if err := unix.Ftruncate(fd, 4); err != nil {
252+ t.Fatal(err)
253+ }
254+ pool := newID()
255+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
256+ t.Fatal(err)
257+ }
258+ buffer := newID()
259+ req(pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
260+ surf := newID()
261+ req(comp, wayland.CompositorRequestCreateSurface, surf)
262+ xdg := &xdgConfigure{}
263+ xdg.SetID(c.AllocateID())
264+ c.Context().Register(xdg)
265+ req(wm.ID(), xdgshell.WmBaseRequestGetXdgSurface, xdg.ID(), surf)
266+ top := newID()
267+ req(xdg.ID(), xdgshell.SurfaceRequestGetToplevel, top)
268+ text := &textClient{}
269+ text.SetID(c.AllocateID())
270+ c.Context().Register(text)
271+ req(tim, textinput.ZwpTextInputManagerV3RequestGetTextInput, text.ID(), seat)
272+ req(surf, wayland.SurfaceRequestCommit)
273+ for deadline := time.Now().Add(5 * time.Second); xdg.serial == 0; time.Sleep(10 * time.Millisecond) {
274+ if time.Now().After(deadline) {
275+ t.Fatal("no configure")
276+ }
277+ if err := c.Roundtrip(); err != nil {
278+ t.Fatal(err)
279+ }
280+ }
281+ req(xdg.ID(), xdgshell.SurfaceRequestAckConfigure, xdg.serial)
282+ req(surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
283+ req(surf, wayland.SurfaceRequestCommit)
284+ return text
285+}
@@ -0,0 +1,285 @@
1+package app
2+
3+import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "os/exec"
8+ "path/filepath"
9+ "strings"
10+ "sync"
11+ "testing"
12+ "time"
13+
14+ "github.com/bnema/neferwl/internal/adapters/config"
15+ "github.com/bnema/purego-libwayland/protocol/textinput"
16+ "github.com/bnema/purego-libwayland/protocol/wayland"
17+ "github.com/bnema/purego-libwayland/protocol/xdgshell"
18+ "github.com/bnema/wlturbo"
19+ "golang.org/x/sys/unix"
20+)
21+
22+// lockedBuffer is an io.Writer read while exec writes to it.
23+type lockedBuffer struct {
24+ mu sync.Mutex
25+ buf bytes.Buffer
26+}
27+
28+func (b *lockedBuffer) Write(p []byte) (int, error) {
29+ b.mu.Lock()
30+ defer b.mu.Unlock()
31+ return b.buf.Write(p)
32+}
33+
34+func (b *lockedBuffer) String() string {
35+ b.mu.Lock()
36+ defer b.mu.Unlock()
37+ return b.buf.String()
38+}
39+
40+// textClient records what a text input receives.
41+type textClient struct {
42+ wlturbo.BaseProxy
43+ entered bool
44+ preedit []string
45+ commits []string
46+}
47+
48+func (p *textClient) Dispatch(e *wlturbo.Event) {
49+ switch uint32(e.Opcode) {
50+ case textinput.ZwpTextInputV3EventEnter:
51+ p.entered = true
52+ case textinput.ZwpTextInputV3EventPreeditString:
53+ p.preedit = append(p.preedit, e.String())
54+ case textinput.ZwpTextInputV3EventCommitString:
55+ p.commits = append(p.commits, e.String())
56+ }
57+}
58+
59+// xdgConfigure records the last configure serial.
60+type xdgConfigure struct {
61+ wlturbo.BaseProxy
62+ serial uint32
63+}
64+
65+func (p *xdgConfigure) Dispatch(e *wlturbo.Event) {
66+ if uint32(e.Opcode) == xdgshell.SurfaceEventConfigure {
67+ p.serial = e.Uint32()
68+ }
69+}
70+
71+// quiet ignores the events of objects the test does not inspect.
72+type quiet struct{ wlturbo.BaseProxy }
73+
74+func (*quiet) Dispatch(*wlturbo.Event) {}
75+
76+type wmPing struct {
77+ wlturbo.BaseProxy
78+ c *wlturbo.Display
79+}
80+
81+func (p *wmPing) Dispatch(e *wlturbo.Event) {
82+ if uint32(e.Opcode) == xdgshell.WmBaseEventPing {
83+ _ = p.c.SendRequest(p.ID(), uint16(xdgshell.WmBaseRequestPong), e.Uint32())
84+ }
85+}
86+
87+// NeferWL headless, the examples/testime input method and a text input
88+// client: the input method's preedit and commit reach the client.
89+func TestHeadlessTextInputIME(t *testing.T) {
90+ runtime := t.TempDir()
91+ t.Setenv("XDG_RUNTIME_DIR", runtime)
92+ t.Setenv("XDG_STATE_HOME", t.TempDir())
93+ ime := filepath.Join(t.TempDir(), "testime")
94+ build := exec.Command("go", "build", "-o", ime, "../../examples/testime")
95+ build.Env = append(os.Environ(), "CGO_ENABLED=0")
96+ if out, err := build.CombinedOutput(); err != nil {
97+ t.Fatalf("build testime: %v: %s", err, out)
98+ }
99+
100+ ctx, cancel := context.WithCancel(context.Background())
101+ done := make(chan error, 1)
102+ go func() {
103+ done <- Run(ctx, Options{Backend: "headless", NoXwayland: true, NoTerminal: true, Config: config.Defaults()})
104+ }()
105+ stopped := false // Run's result was already received
106+ defer func() {
107+ cancel()
108+ if stopped {
109+ return
110+ }
111+ select {
112+ case err := <-done:
113+ if err != nil {
114+ t.Errorf("Run: %v", err)
115+ }
116+ case <-time.After(5 * time.Second):
117+ t.Error("Run did not stop")
118+ }
119+ }()
120+ socket := filepath.Join(runtime, "wayland-1")
121+ for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {
122+ if _, err := os.Stat(socket); err == nil {
123+ break
124+ }
125+ select {
126+ case err := <-done:
127+ stopped = true
128+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "vulkan") {
129+ t.Skipf("Vulkan unavailable: %v", err)
130+ }
131+ t.Fatalf("neferwl exited: %v", err)
132+ default:
133+ }
134+ if time.Now().After(deadline) {
135+ t.Fatal("no wayland socket")
136+ }
137+ }
138+
139+ // Two commits: the second comes from enabling the text input again.
140+ cmd := exec.Command(ime, "-count", "2", "-preedit", "nihon", "-commit", "日本")
141+ cmd.Env = append(os.Environ(), "WAYLAND_DISPLAY=wayland-1")
142+ imeOut := &lockedBuffer{}
143+ cmd.Stderr = imeOut
144+ if err := cmd.Start(); err != nil {
145+ t.Fatal(err)
146+ }
147+ imeDone := make(chan error, 1)
148+ go func() { imeDone <- cmd.Wait() }()
149+ imeExited := false
150+ defer func() {
151+ if !imeExited {
152+ _ = cmd.Process.Kill()
153+ <-imeDone
154+ }
155+ }()
156+ for deadline := time.Now().Add(5 * time.Second); !strings.Contains(imeOut.String(), "ready"); time.Sleep(10 * time.Millisecond) {
157+ if time.Now().After(deadline) {
158+ t.Fatalf("testime not ready: %s", imeOut.String())
159+ }
160+ }
161+
162+ c, err := wlturbo.Connect(socket)
163+ if err != nil {
164+ t.Fatal(err)
165+ }
166+ defer c.Close()
167+ text := mapTextClient(t, c)
168+ poll := func(what string, ok func() bool) {
169+ t.Helper()
170+ for deadline := time.Now().Add(5 * time.Second); !ok(); time.Sleep(10 * time.Millisecond) {
171+ if time.Now().After(deadline) {
172+ t.Fatalf("%s: preedit %q commits %q, testime: %s", what, text.preedit, text.commits, imeOut.String())
173+ }
174+ if err := c.Roundtrip(); err != nil {
175+ t.Fatal(err)
176+ }
177+ }
178+ }
179+ poll("no text input enter", func() bool { return text.entered })
180+ send := func(op uint32, args ...any) {
181+ t.Helper()
182+ if err := c.SendRequest(text.ID(), uint16(op), args...); err != nil {
183+ t.Fatal(err)
184+ }
185+ }
186+ for i := 1; i <= 2; i++ {
187+ send(textinput.ZwpTextInputV3RequestEnable)
188+ send(textinput.ZwpTextInputV3RequestSetContentType, uint32(0), uint32(0))
189+ send(textinput.ZwpTextInputV3RequestCommit)
190+ poll("missing commit", func() bool { return len(text.commits) == i })
191+ }
192+ if len(text.preedit) != 2 || text.preedit[0] != "nihon" || text.commits[0] != "日本" || text.commits[1] != "日本" {
193+ t.Fatalf("preedit %q commits %q", text.preedit, text.commits)
194+ }
195+ select {
196+ case err := <-imeDone:
197+ imeExited = true
198+ if err != nil {
199+ t.Fatalf("testime: %v: %s", err, imeOut.String())
200+ }
201+ case <-time.After(5 * time.Second):
202+ t.Fatalf("testime did not exit after two commits: %s", imeOut.String())
203+ }
204+}
205+
206+// mapTextClient maps a 1x1 toplevel with a text input on c.
207+func mapTextClient(t *testing.T, c *wlturbo.Display) *textClient {
208+ t.Helper()
209+ if err := c.Roundtrip(); err != nil {
210+ t.Fatal(err)
211+ }
212+ bind := func(iface string) uint32 {
213+ t.Helper()
214+ g, ok := c.Registry().FindGlobal(iface)
215+ if !ok {
216+ t.Fatalf("missing %s", iface)
217+ }
218+ id, err := c.Registry().BindID(g.Name, g.Interface, 1)
219+ if err != nil {
220+ t.Fatal(err)
221+ }
222+ return id
223+ }
224+ // New IDs are used in allocation order; every object gets a proxy.
225+ newID := func() uint32 {
226+ p := &quiet{}
227+ p.SetID(c.AllocateID())
228+ c.Context().Register(p)
229+ return p.ID()
230+ }
231+ req := func(id, op uint32, args ...any) {
232+ t.Helper()
233+ if err := c.SendRequest(id, uint16(op), args...); err != nil {
234+ t.Fatal(err)
235+ }
236+ }
237+ comp, shm, seat, tim := bind("wl_compositor"), bind("wl_shm"), bind("wl_seat"), bind("zwp_text_input_manager_v3")
238+ for _, id := range []uint32{comp, shm, seat, tim} {
239+ p := &quiet{}
240+ p.SetID(id)
241+ c.Context().Register(p)
242+ }
243+ wm := &wmPing{c: c}
244+ wm.SetID(bind("xdg_wm_base"))
245+ c.Context().Register(wm)
246+ fd, err := unix.MemfdCreate("text-client", 0)
247+ if err != nil {
248+ t.Fatal(err)
249+ }
250+ defer unix.Close(fd)
251+ if err := unix.Ftruncate(fd, 4); err != nil {
252+ t.Fatal(err)
253+ }
254+ pool := newID()
255+ if err := c.SendRequestWithFDs(shm, uint16(wayland.ShmRequestCreatePool), []int{fd}, pool, int32(4)); err != nil {
256+ t.Fatal(err)
257+ }
258+ buffer := newID()
259+ req(pool, wayland.ShmPoolRequestCreateBuffer, buffer, int32(0), int32(1), int32(1), int32(4), uint32(0))
260+ surf := newID()
261+ req(comp, wayland.CompositorRequestCreateSurface, surf)
262+ xdg := &xdgConfigure{}
263+ xdg.SetID(c.AllocateID())
264+ c.Context().Register(xdg)
265+ req(wm.ID(), xdgshell.WmBaseRequestGetXdgSurface, xdg.ID(), surf)
266+ top := newID()
267+ req(xdg.ID(), xdgshell.SurfaceRequestGetToplevel, top)
268+ text := &textClient{}
269+ text.SetID(c.AllocateID())
270+ c.Context().Register(text)
271+ req(tim, textinput.ZwpTextInputManagerV3RequestGetTextInput, text.ID(), seat)
272+ req(surf, wayland.SurfaceRequestCommit)
273+ for deadline := time.Now().Add(5 * time.Second); xdg.serial == 0; time.Sleep(10 * time.Millisecond) {
274+ if time.Now().After(deadline) {
275+ t.Fatal("no configure")
276+ }
277+ if err := c.Roundtrip(); err != nil {
278+ t.Fatal(err)
279+ }
280+ }
281+ req(xdg.ID(), xdgshell.SurfaceRequestAckConfigure, xdg.serial)
282+ req(surf, wayland.SurfaceRequestAttach, buffer, int32(0), int32(0))
283+ req(surf, wayland.SurfaceRequestCommit)
284+ return text
285+}

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 25

Tags 10

↑↓ moveenter openesc close