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 Unverified
B5690EEEBB952194The 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 | |||
| 27 | 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 | 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 | 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 | 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 | 33 | Config example: | |
| @@ -96,8 +96,8 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil | |||
| 96 | 96 | ## Documentation | |
| 97 | 97 | ||
| 98 | 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. | |
| 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. | |
| 101 | 101 | - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers. | |
| 102 | 102 | ||
| 103 | 103 | ## 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 | ## Documentation | 96 | ## 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 | |||
| 47 | 47 | ||
| 48 | 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 | 60 | ## X11 apps | |
| 51 | 61 | ||
| 52 | 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. | |
| @@ -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 apps | 60 | ## 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 | |||
| 19 | 19 | - `move <x> <y>` (output coordinates) | |
| 20 | 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 | 31 | 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 { | |||
| 31 | 31 | func() error { return registerCapture(d, s) }, | |
| 32 | 32 | func() error { return registerActivation(d, s) }, | |
| 33 | 33 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 34 | + | func() error { return registerTextInput(d, s) }, | |
| 35 | + | func() error { return registerInputMethod(d, s) }, | |
| 34 | 36 | func() error { | |
| 35 | 37 | return wayland.NewCompositorGlobal(d, 6, func(c server.Client, v, id uint32) { wayland.NewCompositor(c, int32(v), id, compositor{s}) }) | |
| 36 | 38 | }, | |
| @@ -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 { | |||
| 125 | 125 | modState ports.ModState | |
| 126 | 126 | keyboards map[server.Client][]*wayland.Keyboard | |
| 127 | 127 | heldKeys map[uint32]bool | |
| 128 | + | grabKeys map[uint32]bool // pressed through the input method grab | |
| 128 | 129 | keymapFD int | |
| 129 | 130 | keymapSize uint32 | |
| 130 | 131 | keymapText string // the seat keymap, to compare virtual keymaps with | |
| @@ -192,6 +193,11 @@ type Server struct { | |||
| 192 | 193 | constraints map[*surface]*constraint | |
| 193 | 194 | constraint *constraint // the active one | |
| 194 | 195 | 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 | 203 | func removeItem[T comparable](list []T, v T) []T { | |
| @@ -633,6 +639,9 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 633 | 639 | s.changeFocus(c.ID) | |
| 634 | 640 | } | |
| 635 | 641 | case ports.ForwardKey: | |
| 642 | + | if s.grabKey(c) { | |
| 643 | + | return | |
| 644 | + | } | |
| 636 | 645 | _, keyboards := s.focusTarget(c.ID) | |
| 637 | 646 | if c.ID != s.focused || len(keyboards) == 0 { | |
| 638 | 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 | 828 | s.log.Info().Int("rate", s.repeatRate).Int("delay", s.repeatDelay).Msg("repeat updated") | |
| 819 | 829 | return | |
| 820 | 830 | } | |
| @@ -830,7 +840,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) { | |||
| 830 | 840 | s.keymapOwner = nil | |
| 831 | 841 | focused := s.focused | |
| 832 | 842 | s.changeFocus(0) | |
| 833 | - | s.heldKeys = nil | |
| 843 | + | s.heldKeys, s.grabKeys = nil, nil | |
| 834 | 844 | s.modState = ports.ModState{} | |
| 835 | 845 | for _, list := range s.keyboards { | |
| 836 | 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 | 854 | s.changeFocus(focused) | |
| 844 | 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 | 891 | s.sendModifiers(k) | |
| 881 | 892 | } | |
| 882 | 893 | } | |
| 894 | + | s.textInputFocus() | |
| 883 | 895 | s.log.Debug().Uint64("id", uint64(s.focused)).Msg("keyboard focus") | |
| 884 | 896 | s.updateConstraint() | |
| 885 | 897 | } | |
| @@ -125,6 +125,7 @@ type Server struct { | |||||
| 125 | modState ports.ModState | 125 | modState ports.ModState | ||
| 126 | keyboards map[server.Client][]*wayland.Keyboard | 126 | keyboards map[server.Client][]*wayland.Keyboard | ||
| 127 | heldKeys map[uint32]bool | 127 | heldKeys map[uint32]bool | ||
| 128 | + | grabKeys map[uint32]bool // pressed through the input method grab | |||
| 128 | keymapFD int | 129 | keymapFD int | ||
| 129 | keymapSize uint32 | 130 | keymapSize uint32 | ||
| 130 | keymapText string // the seat keymap, to compare virtual keymaps with | 131 | keymapText string // the seat keymap, to compare virtual keymaps with | ||
| @@ -192,6 +193,11 @@ type Server struct { | |||||
| 192 | constraints map[*surface]*constraint | 193 | constraints map[*surface]*constraint | ||
| 193 | constraint *constraint // the active one | 194 | constraint *constraint // the active one | ||
| 194 | positioners map[*server.Resource]*positioner | 195 | 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 | return | 829 | return | ||
| 820 | } | 830 | } | ||
| @@ -830,7 +840,7 @@ func (s *Server) setKeymap(c ports.SetKeymap) { | |||||
| 830 | s.keymapOwner = nil | 840 | s.keymapOwner = nil | ||
| 831 | focused := s.focused | 841 | focused := s.focused | ||
| 832 | s.changeFocus(0) | 842 | s.changeFocus(0) | ||
| 833 | - | s.heldKeys = nil | 843 | + | 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 ( | |||
| 21 | 21 | roleSubsurface | |
| 22 | 22 | roleLayer | |
| 23 | 23 | roleCursor | |
| 24 | + | roleInputPopup | |
| 24 | 25 | ) | |
| 25 | 26 | ||
| 26 | 27 | type surface struct { | |
| @@ -21,6 +21,7 @@ const ( | |||||
| 21 | roleSubsurface | 21 | roleSubsurface | ||
| 22 | roleLayer | 22 | roleLayer | ||
| 23 | roleCursor | 23 | 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 | + | } | |||