feat(clipboard): clipboard, primary selection, data-control and startup commands
- commit
- 0ba02a4fc0c139386895e7defba97de66ae4f099
- author
- brice · · 2026-09-25 10:26 UTC
- parent
- 4ca9e48
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +911 -28
M README.md +19 -0
| @@ -25,12 +25,31 @@ keyboard.layout = fr | |||
| 25 | 25 | terminal = foot | |
| 26 | 26 | bind.cmd+return = spawn-terminal | |
| 27 | 27 | bind.ctrl+cmd+space = spawn fuzzel | |
| 28 | + | startup = wl-paste --watch cliphist store | |
| 28 | 29 | ``` | |
| 29 | 30 | ||
| 30 | 31 | See `examples/config` for every key and its default. A missing file means defaults. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. Every key applies live when the file changes, including the keyboard layout; each reload logs the keys that changed. | |
| 31 | 32 | ||
| 32 | 33 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | |
| 33 | 34 | ||
| 35 | + | ## Clipboard | |
| 36 | + | ||
| 37 | + | NeferTTY supports the clipboard (`wl_data_device`), the primary selection (middle-click paste) and `ext_data_control_v1` for clipboard managers. With [cliphist](https://github.com/sentriz/cliphist): | |
| 38 | + | ||
| 39 | + | ```text | |
| 40 | + | startup = wl-paste --watch cliphist store | |
| 41 | + | bind.cmd+v = spawn cliphist-pick | |
| 42 | + | ``` | |
| 43 | + | ||
| 44 | + | Commands are split on spaces and run without a shell, so the picker is a script on your `PATH`, for example `cliphist-pick`: | |
| 45 | + | ||
| 46 | + | ```sh | |
| 47 | + | #!/bin/sh | |
| 48 | + | cliphist list | fuzzel --dmenu | cliphist decode | wl-copy | |
| 49 | + | ``` | |
| 50 | + | ||
| 51 | + | Drag and drop is not supported yet. | |
| 52 | + | ||
| 34 | 53 | ## State for scripts | |
| 35 | 54 | ||
| 36 | 55 | While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace. | |
| @@ -25,12 +25,31 @@ keyboard.layout = fr | |||||
| 25 | terminal = foot | 25 | terminal = foot | ||
| 26 | bind.cmd+return = spawn-terminal | 26 | bind.cmd+return = spawn-terminal | ||
| 27 | bind.ctrl+cmd+space = spawn fuzzel | 27 | bind.ctrl+cmd+space = spawn fuzzel | ||
| 28 | + | startup = wl-paste --watch cliphist store | |||
| 28 | ``` | 29 | ``` | ||
| 29 | 30 | ||||
| 30 | See `examples/config` for every key and its default. A missing file means defaults. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. Every key applies live when the file changes, including the keyboard layout; each reload logs the keys that changed. | 31 | See `examples/config` for every key and its default. A missing file means defaults. An invalid line logs a warning and keeps that key's default; the rest of the file still applies. Every key applies live when the file changes, including the keyboard layout; each reload logs the keys that changed. | ||
| 31 | 32 | ||||
| 32 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | 33 | Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published. | ||
| 33 | 34 | ||||
| 35 | + | ## Clipboard | |||
| 36 | + | ||||
| 37 | + | NeferTTY supports the clipboard (`wl_data_device`), the primary selection (middle-click paste) and `ext_data_control_v1` for clipboard managers. With [cliphist](https://github.com/sentriz/cliphist): | |||
| 38 | + | ||||
| 39 | + | ```text | |||
| 40 | + | startup = wl-paste --watch cliphist store | |||
| 41 | + | bind.cmd+v = spawn cliphist-pick | |||
| 42 | + | ``` | |||
| 43 | + | ||||
| 44 | + | Commands are split on spaces and run without a shell, so the picker is a script on your `PATH`, for example `cliphist-pick`: | |||
| 45 | + | ||||
| 46 | + | ```sh | |||
| 47 | + | #!/bin/sh | |||
| 48 | + | cliphist list | fuzzel --dmenu | cliphist decode | wl-copy | |||
| 49 | + | ``` | |||
| 50 | + | ||||
| 51 | + | Drag and drop is not supported yet. | |||
| 52 | + | ||||
| 34 | ## State for scripts | 53 | ## State for scripts | ||
| 35 | 54 | ||||
| 36 | While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace. | 55 | While it runs, NeferTTY keeps its state in `$XDG_RUNTIME_DIR/nefertty/<wayland socket>.json` and passes that path to the programs it starts as `NEFERTTY_STATE`. The file lists every output (active numbered workspace, workspace count, name of the workspace on screen), the focused output and window, and every window with its app ID, PID, output and workspace. | ||
M examples/config +6 -0
| @@ -14,6 +14,12 @@ keyboard.cmd = super | |||
| 14 | 14 | # Command for spawn-terminal and the terminal opened at startup. | |
| 15 | 15 | terminal = foot | |
| 16 | 16 | ||
| 17 | + | # Commands run once when the session starts, one line each. Arguments are | |
| 18 | + | # split on spaces and there is no shell: put pipes in a script. | |
| 19 | + | # Programs get WAYLAND_DISPLAY and NEFERTTY_STATE. | |
| 20 | + | # startup = waybar | |
| 21 | + | # startup = wl-paste --watch cliphist store | |
| 22 | + | ||
| 17 | 23 | background = #111111 | |
| 18 | 24 | ||
| 19 | 25 | # Drawn inside the window edge; width 0 disables it, empty inactive draws nothing. | |
| @@ -14,6 +14,12 @@ keyboard.cmd = super | |||||
| 14 | # Command for spawn-terminal and the terminal opened at startup. | 14 | # Command for spawn-terminal and the terminal opened at startup. | ||
| 15 | terminal = foot | 15 | terminal = foot | ||
| 16 | 16 | ||||
| 17 | + | # Commands run once when the session starts, one line each. Arguments are | |||
| 18 | + | # split on spaces and there is no shell: put pipes in a script. | |||
| 19 | + | # Programs get WAYLAND_DISPLAY and NEFERTTY_STATE. | |||
| 20 | + | # startup = waybar | |||
| 21 | + | # startup = wl-paste --watch cliphist store | |||
| 22 | + | ||||
| 17 | background = #111111 | 23 | background = #111111 | ||
| 18 | 24 | ||||
| 19 | # Drawn inside the window edge; width 0 disables it, empty inactive draws nothing. | 25 | # Drawn inside the window edge; width 0 disables it, empty inactive draws nothing. | ||
M internal/adapters/config/config.go +11 -0
| @@ -262,6 +262,11 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||
| 262 | 262 | warn("%s: %v", key, err) | |
| 263 | 263 | continue | |
| 264 | 264 | } | |
| 265 | + | if key == "startup" { | |
| 266 | + | // Repeatable: each line adds a command. | |
| 267 | + | raw[fmt.Sprintf("startup.%d", len(c.Startup))] = value | |
| 268 | + | continue | |
| 269 | + | } | |
| 265 | 270 | override() | |
| 266 | 271 | } | |
| 267 | 272 | if err := scanner.Err(); err != nil { | |
| @@ -371,6 +376,12 @@ func set(c *ports.Config, key, v string) error { | |||
| 371 | 376 | return fmt.Errorf("must be super, alt or ctrl") | |
| 372 | 377 | } | |
| 373 | 378 | c.Keyboard.CmdKey = v | |
| 379 | + | case "startup": | |
| 380 | + | argv := strings.Fields(v) | |
| 381 | + | if len(argv) == 0 { | |
| 382 | + | return fmt.Errorf("must not be empty") | |
| 383 | + | } | |
| 384 | + | c.Startup = append(c.Startup, argv) | |
| 374 | 385 | case "terminal": | |
| 375 | 386 | argv := strings.Fields(v) | |
| 376 | 387 | if len(argv) == 0 { | |
| @@ -262,6 +262,11 @@ func parse(r io.Reader) (ports.Config, map[string]string, []Warning, error) { | |||||
| 262 | warn("%s: %v", key, err) | 262 | warn("%s: %v", key, err) | ||
| 263 | continue | 263 | continue | ||
| 264 | } | 264 | } | ||
| 265 | + | if key == "startup" { | |||
| 266 | + | // Repeatable: each line adds a command. | |||
| 267 | + | raw[fmt.Sprintf("startup.%d", len(c.Startup))] = value | |||
| 268 | + | continue | |||
| 269 | + | } | |||
| 265 | override() | 270 | override() | ||
| 266 | } | 271 | } | ||
| 267 | if err := scanner.Err(); err != nil { | 272 | if err := scanner.Err(); err != nil { | ||
| @@ -371,6 +376,12 @@ func set(c *ports.Config, key, v string) error { | |||||
| 371 | return fmt.Errorf("must be super, alt or ctrl") | 376 | return fmt.Errorf("must be super, alt or ctrl") | ||
| 372 | } | 377 | } | ||
| 373 | c.Keyboard.CmdKey = v | 378 | c.Keyboard.CmdKey = v | ||
| 379 | + | case "startup": | |||
| 380 | + | argv := strings.Fields(v) | |||
| 381 | + | if len(argv) == 0 { | |||
| 382 | + | return fmt.Errorf("must not be empty") | |||
| 383 | + | } | |||
| 384 | + | c.Startup = append(c.Startup, argv) | |||
| 374 | case "terminal": | 385 | case "terminal": | ||
| 375 | argv := strings.Fields(v) | 386 | argv := strings.Fields(v) | ||
| 376 | if len(argv) == 0 { | 387 | if len(argv) == 0 { | ||
M internal/adapters/config/config_test.go +5 -0
| @@ -62,10 +62,15 @@ output.DP-1.scale = 1.5 | |||
| 62 | 62 | output.DP-1.primary = on | |
| 63 | 63 | bind.cmd+code:30 = quit | |
| 64 | 64 | render.direct-scanout = off | |
| 65 | + | startup = wl-paste --watch cliphist store | |
| 66 | + | startup = wl-paste --primary --watch cliphist store | |
| 65 | 67 | `) | |
| 66 | 68 | if len(w) != 0 { | |
| 67 | 69 | t.Fatal(w) | |
| 68 | 70 | } | |
| 71 | + | if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}}) { | |
| 72 | + | t.Fatalf("startup: %q", c.Startup) | |
| 73 | + | } | |
| 69 | 74 | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout { | |
| 70 | 75 | t.Fatalf("%+v", c) | |
| 71 | 76 | } | |
| @@ -62,10 +62,15 @@ output.DP-1.scale = 1.5 | |||||
| 62 | output.DP-1.primary = on | 62 | output.DP-1.primary = on | ||
| 63 | bind.cmd+code:30 = quit | 63 | bind.cmd+code:30 = quit | ||
| 64 | render.direct-scanout = off | 64 | render.direct-scanout = off | ||
| 65 | + | startup = wl-paste --watch cliphist store | |||
| 66 | + | startup = wl-paste --primary --watch cliphist store | |||
| 65 | `) | 67 | `) | ||
| 66 | if len(w) != 0 { | 68 | if len(w) != 0 { | ||
| 67 | t.Fatal(w) | 69 | t.Fatal(w) | ||
| 68 | } | 70 | } | ||
| 71 | + | if !reflect.DeepEqual(c.Startup, [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"wl-paste", "--primary", "--watch", "cliphist", "store"}}) { | |||
| 72 | + | t.Fatalf("startup: %q", c.Startup) | |||
| 73 | + | } | |||
| 69 | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout { | 74 | if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout { | ||
| 70 | t.Fatalf("%+v", c) | 75 | t.Fatalf("%+v", c) | ||
| 71 | } | 76 | } | ||
A internal/adapters/wayland/clipboard.go +471 -0
| @@ -0,0 +1,471 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/purego-libwayland/protocol/extdatacontrol" | |
| 5 | + | "github.com/bnema/purego-libwayland/protocol/primaryselection" | |
| 6 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 7 | + | "github.com/bnema/purego-libwayland/server" | |
| 8 | + | "golang.org/x/sys/unix" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // The clipboard (wl_data_device) and the primary selection | |
| 12 | + | // (zwp_primary_selection_v1) each hold one source. Only the client with | |
| 13 | + | // keyboard focus may set them and sees them; clipboard managers such as | |
| 14 | + | // cliphist and wl-clipboard use ext_data_control_v1, which sees and sets | |
| 15 | + | // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client: | |
| 16 | + | // the data never goes through nefertty. Drag and drop is not implemented. | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | selClipboard = iota | |
| 20 | + | selPrimary | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | // clipSource is a client's offer: its MIME types and how to ask for data. | |
| 24 | + | type clipSource struct { | |
| 25 | + | client server.Client | |
| 26 | + | mimes []string | |
| 27 | + | send func(mime string, fd int) | |
| 28 | + | cancel func() | |
| 29 | + | alive func() bool | |
| 30 | + | // used: the source was set, rejected or dragged; it cannot be set | |
| 31 | + | // again and takes no more MIME types. | |
| 32 | + | used bool | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // addMime records a MIME type offered before the source is used. | |
| 36 | + | func (src *clipSource) addMime(mime string) bool { | |
| 37 | + | if src.used { | |
| 38 | + | return false | |
| 39 | + | } | |
| 40 | + | src.mimes = append(src.mimes, mime) | |
| 41 | + | return true | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // clipDevice receives selections for one client. | |
| 45 | + | type clipDevice struct { | |
| 46 | + | client server.Client | |
| 47 | + | // control devices (ext_data_control_v1) see every selection. | |
| 48 | + | control bool | |
| 49 | + | kinds [2]bool | |
| 50 | + | alive func() bool | |
| 51 | + | // offer announces src (nil clears) as the selection of kind. | |
| 52 | + | offer func(kind int, src *clipSource) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func registerClipboard(d *server.Display, s *Server) error { | |
| 56 | + | for _, register := range []func() error{ | |
| 57 | + | func() error { | |
| 58 | + | return wayland.NewDataDeviceManagerGlobal(d, 3, func(c server.Client, v, id uint32) { | |
| 59 | + | _, _ = wayland.NewDataDeviceManager(c, int32(v), id, dataManager{s}) | |
| 60 | + | }) | |
| 61 | + | }, | |
| 62 | + | func() error { | |
| 63 | + | return primaryselection.NewZwpPrimarySelectionDeviceManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 64 | + | _, _ = primaryselection.NewZwpPrimarySelectionDeviceManagerV1(c, int32(v), id, primaryManager{s}) | |
| 65 | + | }) | |
| 66 | + | }, | |
| 67 | + | func() error { | |
| 68 | + | return extdatacontrol.NewExtDataControlManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 69 | + | _, _ = extdatacontrol.NewExtDataControlManagerV1(c, int32(v), id, controlManager{s}) | |
| 70 | + | }) | |
| 71 | + | }, | |
| 72 | + | } { | |
| 73 | + | if err := register(); err != nil { | |
| 74 | + | return err | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // focusedClient is the client with keyboard focus. | |
| 81 | + | func (s *Server) focusedClient() (server.Client, bool) { | |
| 82 | + | surf, _ := s.focusTarget(s.focused) | |
| 83 | + | if surf == nil { | |
| 84 | + | return server.Client{}, false | |
| 85 | + | } | |
| 86 | + | return surf.Client(), true | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // setSelection makes src the selection of kind and announces it. A | |
| 90 | + | // replaced source is cancelled. | |
| 91 | + | func (s *Server) setSelection(kind int, src *clipSource) { | |
| 92 | + | old := s.selections[kind] | |
| 93 | + | if old == src { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | s.selections[kind] = src | |
| 97 | + | if old != nil && old.alive() { | |
| 98 | + | old.cancel() | |
| 99 | + | } | |
| 100 | + | s.announceAll(kind) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // announceAll sends the selection of kind to the focused client and to | |
| 104 | + | // clipboard managers. | |
| 105 | + | func (s *Server) announceAll(kind int) { | |
| 106 | + | c, ok := s.focusedClient() | |
| 107 | + | s.announce(kind, func(d *clipDevice) bool { return d.control || ok && d.client == c }) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // announce sends the selection of kind to the devices matching want. | |
| 111 | + | func (s *Server) announce(kind int, want func(*clipDevice) bool) { | |
| 112 | + | src := s.selections[kind] | |
| 113 | + | if src != nil && !src.alive() { | |
| 114 | + | src, s.selections[kind] = nil, nil | |
| 115 | + | } | |
| 116 | + | alive := s.clipDevices[:0] | |
| 117 | + | for _, d := range s.clipDevices { | |
| 118 | + | if !d.alive() { | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | alive = append(alive, d) | |
| 122 | + | if d.kinds[kind] && want(d) { | |
| 123 | + | d.offer(kind, src) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | clear(s.clipDevices[len(alive):]) | |
| 127 | + | s.clipDevices = alive | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // focusSelections gives a client that just got keyboard focus the current | |
| 131 | + | // selections, as the protocol asks before its keyboard enter. | |
| 132 | + | func (s *Server) focusSelections() { | |
| 133 | + | c, ok := s.focusedClient() | |
| 134 | + | if !ok { | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | for kind := range s.selections { | |
| 138 | + | s.announce(kind, func(d *clipDevice) bool { return !d.control && d.client == c }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // addClipDevice tracks a device and sends it the selections it may see. | |
| 143 | + | func (s *Server) addClipDevice(d *clipDevice) { | |
| 144 | + | s.clipDevices = append(s.clipDevices, d) | |
| 145 | + | c, ok := s.focusedClient() | |
| 146 | + | if !d.control && (!ok || d.client != c) { | |
| 147 | + | return | |
| 148 | + | } | |
| 149 | + | for kind, src := range s.selections { | |
| 150 | + | if d.kinds[kind] { | |
| 151 | + | if src != nil && !src.alive() { | |
| 152 | + | src = nil | |
| 153 | + | } | |
| 154 | + | d.offer(kind, src) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // sourceGone clears the selections held by a destroyed source. | |
| 160 | + | func (s *Server) sourceGone(src *clipSource) { | |
| 161 | + | for kind := range s.selections { | |
| 162 | + | if s.selections[kind] == src { | |
| 163 | + | s.selections[kind] = nil | |
| 164 | + | s.announceAll(kind) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // maySet reports whether a client may set the selection: only the | |
| 170 | + | // focused one. Any client holding an offer may read it. | |
| 171 | + | func (s *Server) maySet(c server.Client) bool { | |
| 172 | + | f, ok := s.focusedClient() | |
| 173 | + | return ok && f == c | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // setFocused sets a selection from a focus-bound device. A used source is | |
| 177 | + | // ignored (it was already cancelled unless it is the current one); an | |
| 178 | + | // unfocused client's source is cancelled. | |
| 179 | + | func (s *Server) setFocused(c server.Client, kind int, src *clipSource) { | |
| 180 | + | if src != nil && src.used { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | if src != nil { | |
| 184 | + | src.used = true | |
| 185 | + | } | |
| 186 | + | if !s.maySet(c) { | |
| 187 | + | if src != nil { | |
| 188 | + | src.cancel() | |
| 189 | + | } | |
| 190 | + | return | |
| 191 | + | } | |
| 192 | + | s.setSelection(kind, src) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // receive hands the reader's pipe to the source if it is still the | |
| 196 | + | // selection; the fd is always closed here (the event dups it). | |
| 197 | + | func (s *Server) receive(kind int, src *clipSource, mime string, fd int) { | |
| 198 | + | defer unix.Close(fd) | |
| 199 | + | if s.selections[kind] != src || !src.alive() { | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | for _, m := range src.mimes { | |
| 203 | + | if m == mime { | |
| 204 | + | src.send(mime, fd) | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // wl_data_device_manager | |
| 211 | + | ||
| 212 | + | type dataManager struct{ server *Server } | |
| 213 | + | ||
| 214 | + | func (m dataManager) CreateDataSource(r *wayland.DataDeviceManager, id uint32) { | |
| 215 | + | src := &clipSource{client: r.Client()} | |
| 216 | + | res, err := wayland.NewDataSource(r.Client(), r.Version(), id, dataSource{src}) | |
| 217 | + | if err != nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | src.send = func(mime string, fd int) { res.SendSend(mime, fd) } | |
| 221 | + | src.cancel = res.SendCancelled | |
| 222 | + | src.alive = res.Resource.Alive | |
| 223 | + | m.server.dataSources[res.Resource] = src | |
| 224 | + | res.OnDestroy = func() { | |
| 225 | + | delete(m.server.dataSources, res.Resource) | |
| 226 | + | m.server.sourceGone(src) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | func (m dataManager) GetDataDevice(r *wayland.DataDeviceManager, id uint32, _ *wayland.Seat) { | |
| 230 | + | s := m.server | |
| 231 | + | res, err := wayland.NewDataDevice(r.Client(), r.Version(), id, dataDevice{s}) | |
| 232 | + | if err != nil { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selClipboard: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) { | |
| 236 | + | if src == nil { | |
| 237 | + | res.SendSelection(nil) | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | offer, err := wayland.NewDataOffer(res.Client(), res.Version(), 0, dataOffer{s, src}) | |
| 241 | + | if err != nil { | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | res.SendDataOffer(offer) | |
| 245 | + | for _, mime := range src.mimes { | |
| 246 | + | offer.SendOffer(mime) | |
| 247 | + | } | |
| 248 | + | res.SendSelection(offer) | |
| 249 | + | }}) | |
| 250 | + | } | |
| 251 | + | func (dataManager) Release(r *wayland.DataDeviceManager) { r.Destroy() } | |
| 252 | + | ||
| 253 | + | type dataSource struct{ src *clipSource } | |
| 254 | + | ||
| 255 | + | func (d dataSource) Offer(_ *wayland.DataSource, mime string) { d.src.addMime(mime) } | |
| 256 | + | func (dataSource) Destroy(*wayland.DataSource) {} | |
| 257 | + | func (dataSource) SetActions(*wayland.DataSource, uint32) {} | |
| 258 | + | ||
| 259 | + | type dataDevice struct{ server *Server } | |
| 260 | + | ||
| 261 | + | // StartDrag is not implemented: the drag is cancelled. | |
| 262 | + | func (d dataDevice) StartDrag(_ *wayland.DataDevice, src *wayland.DataSource, _, _ *wayland.Surface, _ uint32) { | |
| 263 | + | if src == nil { | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | cs := d.server.dataSources[src.Resource] | |
| 267 | + | if cs == nil || cs.used { | |
| 268 | + | // A used source is the selection or already cancelled. | |
| 269 | + | return | |
| 270 | + | } | |
| 271 | + | cs.used = true | |
| 272 | + | if src.Resource.Alive() { | |
| 273 | + | src.SendCancelled() | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource, _ uint32) { | |
| 277 | + | s := d.server | |
| 278 | + | var src *clipSource | |
| 279 | + | if res != nil { | |
| 280 | + | src = s.dataSources[res.Resource] | |
| 281 | + | if src == nil { | |
| 282 | + | return | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | s.setFocused(r.Client(), selClipboard, src) | |
| 286 | + | } | |
| 287 | + | func (dataDevice) Release(r *wayland.DataDevice) { r.Destroy() } | |
| 288 | + | ||
| 289 | + | type dataOffer struct { | |
| 290 | + | server *Server | |
| 291 | + | src *clipSource | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (dataOffer) Accept(*wayland.DataOffer, uint32, string) {} | |
| 295 | + | func (o dataOffer) Receive(_ *wayland.DataOffer, mime string, fd int) { | |
| 296 | + | o.server.receive(selClipboard, o.src, mime, fd) | |
| 297 | + | } | |
| 298 | + | func (dataOffer) Destroy(*wayland.DataOffer) {} | |
| 299 | + | func (dataOffer) Finish(*wayland.DataOffer) {} | |
| 300 | + | func (dataOffer) SetActions(*wayland.DataOffer, uint32, uint32) {} | |
| 301 | + | ||
| 302 | + | // zwp_primary_selection_device_manager_v1 | |
| 303 | + | ||
| 304 | + | type primaryManager struct{ server *Server } | |
| 305 | + | ||
| 306 | + | func (m primaryManager) CreateSource(r *primaryselection.ZwpPrimarySelectionDeviceManagerV1, id uint32) { | |
| 307 | + | src := &clipSource{client: r.Client()} | |
| 308 | + | res, err := primaryselection.NewZwpPrimarySelectionSourceV1(r.Client(), r.Version(), id, primarySource{src}) | |
| 309 | + | if err != nil { | |
| 310 | + | return | |
| 311 | + | } | |
| 312 | + | src.send = func(mime string, fd int) { res.SendSend(mime, fd) } | |
| 313 | + | src.cancel = res.SendCancelled | |
| 314 | + | src.alive = res.Resource.Alive | |
| 315 | + | m.server.primarySources[res.Resource] = src | |
| 316 | + | res.OnDestroy = func() { | |
| 317 | + | delete(m.server.primarySources, res.Resource) | |
| 318 | + | m.server.sourceGone(src) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | func (m primaryManager) GetDevice(r *primaryselection.ZwpPrimarySelectionDeviceManagerV1, id uint32, _ *wayland.Seat) { | |
| 322 | + | s := m.server | |
| 323 | + | res, err := primaryselection.NewZwpPrimarySelectionDeviceV1(r.Client(), r.Version(), id, primaryDevice{s}) | |
| 324 | + | if err != nil { | |
| 325 | + | return | |
| 326 | + | } | |
| 327 | + | s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selPrimary: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) { | |
| 328 | + | if src == nil { | |
| 329 | + | res.SendSelection(nil) | |
| 330 | + | return | |
| 331 | + | } | |
| 332 | + | offer, err := primaryselection.NewZwpPrimarySelectionOfferV1(res.Client(), res.Version(), 0, primaryOffer{s, src}) | |
| 333 | + | if err != nil { | |
| 334 | + | return | |
| 335 | + | } | |
| 336 | + | res.SendDataOffer(offer) | |
| 337 | + | for _, mime := range src.mimes { | |
| 338 | + | offer.SendOffer(mime) | |
| 339 | + | } | |
| 340 | + | res.SendSelection(offer) | |
| 341 | + | }}) | |
| 342 | + | } | |
| 343 | + | func (primaryManager) Destroy(*primaryselection.ZwpPrimarySelectionDeviceManagerV1) {} | |
| 344 | + | ||
| 345 | + | type primarySource struct{ src *clipSource } | |
| 346 | + | ||
| 347 | + | func (p primarySource) Offer(_ *primaryselection.ZwpPrimarySelectionSourceV1, mime string) { | |
| 348 | + | p.src.addMime(mime) | |
| 349 | + | } | |
| 350 | + | func (primarySource) Destroy(*primaryselection.ZwpPrimarySelectionSourceV1) {} | |
| 351 | + | ||
| 352 | + | type primaryDevice struct{ server *Server } | |
| 353 | + | ||
| 354 | + | func (d primaryDevice) SetSelection(r *primaryselection.ZwpPrimarySelectionDeviceV1, res *primaryselection.ZwpPrimarySelectionSourceV1, _ uint32) { | |
| 355 | + | s := d.server | |
| 356 | + | var src *clipSource | |
| 357 | + | if res != nil { | |
| 358 | + | src = s.primarySources[res.Resource] | |
| 359 | + | if src == nil { | |
| 360 | + | return | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | s.setFocused(r.Client(), selPrimary, src) | |
| 364 | + | } | |
| 365 | + | func (primaryDevice) Destroy(*primaryselection.ZwpPrimarySelectionDeviceV1) {} | |
| 366 | + | ||
| 367 | + | type primaryOffer struct { | |
| 368 | + | server *Server | |
| 369 | + | src *clipSource | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | func (o primaryOffer) Receive(_ *primaryselection.ZwpPrimarySelectionOfferV1, mime string, fd int) { | |
| 373 | + | o.server.receive(selPrimary, o.src, mime, fd) | |
| 374 | + | } | |
| 375 | + | func (primaryOffer) Destroy(*primaryselection.ZwpPrimarySelectionOfferV1) {} | |
| 376 | + | ||
| 377 | + | // ext_data_control_manager_v1 | |
| 378 | + | ||
| 379 | + | type controlManager struct{ server *Server } | |
| 380 | + | ||
| 381 | + | func (m controlManager) CreateDataSource(r *extdatacontrol.ExtDataControlManagerV1, id uint32) { | |
| 382 | + | src := &clipSource{client: r.Client()} | |
| 383 | + | res, err := extdatacontrol.NewExtDataControlSourceV1(r.Client(), r.Version(), id, controlSource{src}) | |
| 384 | + | if err != nil { | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | src.send = func(mime string, fd int) { res.SendSend(mime, fd) } | |
| 388 | + | src.cancel = res.SendCancelled | |
| 389 | + | src.alive = res.Resource.Alive | |
| 390 | + | m.server.controlSources[res.Resource] = src | |
| 391 | + | res.OnDestroy = func() { | |
| 392 | + | delete(m.server.controlSources, res.Resource) | |
| 393 | + | m.server.sourceGone(src) | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | func (m controlManager) GetDataDevice(r *extdatacontrol.ExtDataControlManagerV1, id uint32, _ *wayland.Seat) { | |
| 397 | + | s := m.server | |
| 398 | + | res, err := extdatacontrol.NewExtDataControlDeviceV1(r.Client(), r.Version(), id, controlDevice{s}) | |
| 399 | + | if err != nil { | |
| 400 | + | return | |
| 401 | + | } | |
| 402 | + | s.addClipDevice(&clipDevice{client: r.Client(), control: true, kinds: [2]bool{true, true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) { | |
| 403 | + | send := res.SendSelection | |
| 404 | + | if kind == selPrimary { | |
| 405 | + | send = res.SendPrimarySelection | |
| 406 | + | } | |
| 407 | + | if src == nil { | |
| 408 | + | send(nil) | |
| 409 | + | return | |
| 410 | + | } | |
| 411 | + | offer, err := extdatacontrol.NewExtDataControlOfferV1(res.Client(), res.Version(), 0, controlOffer{s, kind, src}) | |
| 412 | + | if err != nil { | |
| 413 | + | return | |
| 414 | + | } | |
| 415 | + | res.SendDataOffer(offer) | |
| 416 | + | for _, mime := range src.mimes { | |
| 417 | + | offer.SendOffer(mime) | |
| 418 | + | } | |
| 419 | + | send(offer) | |
| 420 | + | }}) | |
| 421 | + | } | |
| 422 | + | func (controlManager) Destroy(*extdatacontrol.ExtDataControlManagerV1) {} | |
| 423 | + | ||
| 424 | + | type controlSource struct{ src *clipSource } | |
| 425 | + | ||
| 426 | + | func (c controlSource) Offer(r *extdatacontrol.ExtDataControlSourceV1, mime string) { | |
| 427 | + | if !c.src.addMime(mime) { | |
| 428 | + | r.PostError(uint32(extdatacontrol.ExtDataControlSourceV1ErrorInvalidOffer), "offer after the source was used") | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | func (controlSource) Destroy(*extdatacontrol.ExtDataControlSourceV1) {} | |
| 432 | + | ||
| 433 | + | type controlDevice struct{ server *Server } | |
| 434 | + | ||
| 435 | + | func (d controlDevice) SetSelection(r *extdatacontrol.ExtDataControlDeviceV1, res *extdatacontrol.ExtDataControlSourceV1) { | |
| 436 | + | d.set(r, selClipboard, res) | |
| 437 | + | } | |
| 438 | + | func (d controlDevice) SetPrimarySelection(r *extdatacontrol.ExtDataControlDeviceV1, res *extdatacontrol.ExtDataControlSourceV1) { | |
| 439 | + | d.set(r, selPrimary, res) | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // set lets a clipboard manager set a selection without focus. A source | |
| 443 | + | // may be used once. | |
| 444 | + | func (d controlDevice) set(r *extdatacontrol.ExtDataControlDeviceV1, kind int, res *extdatacontrol.ExtDataControlSourceV1) { | |
| 445 | + | s := d.server | |
| 446 | + | var src *clipSource | |
| 447 | + | if res != nil { | |
| 448 | + | src = s.controlSources[res.Resource] | |
| 449 | + | if src == nil { | |
| 450 | + | return | |
| 451 | + | } | |
| 452 | + | if src.used { | |
| 453 | + | r.PostError(uint32(extdatacontrol.ExtDataControlDeviceV1ErrorUsedSource), "source already used") | |
| 454 | + | return | |
| 455 | + | } | |
| 456 | + | src.used = true | |
| 457 | + | } | |
| 458 | + | s.setSelection(kind, src) | |
| 459 | + | } | |
| 460 | + | func (controlDevice) Destroy(*extdatacontrol.ExtDataControlDeviceV1) {} | |
| 461 | + | ||
| 462 | + | type controlOffer struct { | |
| 463 | + | server *Server | |
| 464 | + | kind int | |
| 465 | + | src *clipSource | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | func (o controlOffer) Receive(_ *extdatacontrol.ExtDataControlOfferV1, mime string, fd int) { | |
| 469 | + | o.server.receive(o.kind, o.src, mime, fd) | |
| 470 | + | } | |
| 471 | + | func (controlOffer) Destroy(*extdatacontrol.ExtDataControlOfferV1) {} | |
| @@ -0,0 +1,471 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/purego-libwayland/protocol/extdatacontrol" | |||
| 5 | + | "github.com/bnema/purego-libwayland/protocol/primaryselection" | |||
| 6 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 7 | + | "github.com/bnema/purego-libwayland/server" | |||
| 8 | + | "golang.org/x/sys/unix" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // The clipboard (wl_data_device) and the primary selection | |||
| 12 | + | // (zwp_primary_selection_v1) each hold one source. Only the client with | |||
| 13 | + | // keyboard focus may set them and sees them; clipboard managers such as | |||
| 14 | + | // cliphist and wl-clipboard use ext_data_control_v1, which sees and sets | |||
| 15 | + | // both without focus. Like wlroots, any client may bind it. Pasting hands the reader's pipe to the source client: | |||
| 16 | + | // the data never goes through nefertty. Drag and drop is not implemented. | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | selClipboard = iota | |||
| 20 | + | selPrimary | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | // clipSource is a client's offer: its MIME types and how to ask for data. | |||
| 24 | + | type clipSource struct { | |||
| 25 | + | client server.Client | |||
| 26 | + | mimes []string | |||
| 27 | + | send func(mime string, fd int) | |||
| 28 | + | cancel func() | |||
| 29 | + | alive func() bool | |||
| 30 | + | // used: the source was set, rejected or dragged; it cannot be set | |||
| 31 | + | // again and takes no more MIME types. | |||
| 32 | + | used bool | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // addMime records a MIME type offered before the source is used. | |||
| 36 | + | func (src *clipSource) addMime(mime string) bool { | |||
| 37 | + | if src.used { | |||
| 38 | + | return false | |||
| 39 | + | } | |||
| 40 | + | src.mimes = append(src.mimes, mime) | |||
| 41 | + | return true | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // clipDevice receives selections for one client. | |||
| 45 | + | type clipDevice struct { | |||
| 46 | + | client server.Client | |||
| 47 | + | // control devices (ext_data_control_v1) see every selection. | |||
| 48 | + | control bool | |||
| 49 | + | kinds [2]bool | |||
| 50 | + | alive func() bool | |||
| 51 | + | // offer announces src (nil clears) as the selection of kind. | |||
| 52 | + | offer func(kind int, src *clipSource) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func registerClipboard(d *server.Display, s *Server) error { | |||
| 56 | + | for _, register := range []func() error{ | |||
| 57 | + | func() error { | |||
| 58 | + | return wayland.NewDataDeviceManagerGlobal(d, 3, func(c server.Client, v, id uint32) { | |||
| 59 | + | _, _ = wayland.NewDataDeviceManager(c, int32(v), id, dataManager{s}) | |||
| 60 | + | }) | |||
| 61 | + | }, | |||
| 62 | + | func() error { | |||
| 63 | + | return primaryselection.NewZwpPrimarySelectionDeviceManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |||
| 64 | + | _, _ = primaryselection.NewZwpPrimarySelectionDeviceManagerV1(c, int32(v), id, primaryManager{s}) | |||
| 65 | + | }) | |||
| 66 | + | }, | |||
| 67 | + | func() error { | |||
| 68 | + | return extdatacontrol.NewExtDataControlManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |||
| 69 | + | _, _ = extdatacontrol.NewExtDataControlManagerV1(c, int32(v), id, controlManager{s}) | |||
| 70 | + | }) | |||
| 71 | + | }, | |||
| 72 | + | } { | |||
| 73 | + | if err := register(); err != nil { | |||
| 74 | + | return err | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // focusedClient is the client with keyboard focus. | |||
| 81 | + | func (s *Server) focusedClient() (server.Client, bool) { | |||
| 82 | + | surf, _ := s.focusTarget(s.focused) | |||
| 83 | + | if surf == nil { | |||
| 84 | + | return server.Client{}, false | |||
| 85 | + | } | |||
| 86 | + | return surf.Client(), true | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // setSelection makes src the selection of kind and announces it. A | |||
| 90 | + | // replaced source is cancelled. | |||
| 91 | + | func (s *Server) setSelection(kind int, src *clipSource) { | |||
| 92 | + | old := s.selections[kind] | |||
| 93 | + | if old == src { | |||
| 94 | + | return | |||
| 95 | + | } | |||
| 96 | + | s.selections[kind] = src | |||
| 97 | + | if old != nil && old.alive() { | |||
| 98 | + | old.cancel() | |||
| 99 | + | } | |||
| 100 | + | s.announceAll(kind) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // announceAll sends the selection of kind to the focused client and to | |||
| 104 | + | // clipboard managers. | |||
| 105 | + | func (s *Server) announceAll(kind int) { | |||
| 106 | + | c, ok := s.focusedClient() | |||
| 107 | + | s.announce(kind, func(d *clipDevice) bool { return d.control || ok && d.client == c }) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // announce sends the selection of kind to the devices matching want. | |||
| 111 | + | func (s *Server) announce(kind int, want func(*clipDevice) bool) { | |||
| 112 | + | src := s.selections[kind] | |||
| 113 | + | if src != nil && !src.alive() { | |||
| 114 | + | src, s.selections[kind] = nil, nil | |||
| 115 | + | } | |||
| 116 | + | alive := s.clipDevices[:0] | |||
| 117 | + | for _, d := range s.clipDevices { | |||
| 118 | + | if !d.alive() { | |||
| 119 | + | continue | |||
| 120 | + | } | |||
| 121 | + | alive = append(alive, d) | |||
| 122 | + | if d.kinds[kind] && want(d) { | |||
| 123 | + | d.offer(kind, src) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | clear(s.clipDevices[len(alive):]) | |||
| 127 | + | s.clipDevices = alive | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // focusSelections gives a client that just got keyboard focus the current | |||
| 131 | + | // selections, as the protocol asks before its keyboard enter. | |||
| 132 | + | func (s *Server) focusSelections() { | |||
| 133 | + | c, ok := s.focusedClient() | |||
| 134 | + | if !ok { | |||
| 135 | + | return | |||
| 136 | + | } | |||
| 137 | + | for kind := range s.selections { | |||
| 138 | + | s.announce(kind, func(d *clipDevice) bool { return !d.control && d.client == c }) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // addClipDevice tracks a device and sends it the selections it may see. | |||
| 143 | + | func (s *Server) addClipDevice(d *clipDevice) { | |||
| 144 | + | s.clipDevices = append(s.clipDevices, d) | |||
| 145 | + | c, ok := s.focusedClient() | |||
| 146 | + | if !d.control && (!ok || d.client != c) { | |||
| 147 | + | return | |||
| 148 | + | } | |||
| 149 | + | for kind, src := range s.selections { | |||
| 150 | + | if d.kinds[kind] { | |||
| 151 | + | if src != nil && !src.alive() { | |||
| 152 | + | src = nil | |||
| 153 | + | } | |||
| 154 | + | d.offer(kind, src) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // sourceGone clears the selections held by a destroyed source. | |||
| 160 | + | func (s *Server) sourceGone(src *clipSource) { | |||
| 161 | + | for kind := range s.selections { | |||
| 162 | + | if s.selections[kind] == src { | |||
| 163 | + | s.selections[kind] = nil | |||
| 164 | + | s.announceAll(kind) | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | // maySet reports whether a client may set the selection: only the | |||
| 170 | + | // focused one. Any client holding an offer may read it. | |||
| 171 | + | func (s *Server) maySet(c server.Client) bool { | |||
| 172 | + | f, ok := s.focusedClient() | |||
| 173 | + | return ok && f == c | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // setFocused sets a selection from a focus-bound device. A used source is | |||
| 177 | + | // ignored (it was already cancelled unless it is the current one); an | |||
| 178 | + | // unfocused client's source is cancelled. | |||
| 179 | + | func (s *Server) setFocused(c server.Client, kind int, src *clipSource) { | |||
| 180 | + | if src != nil && src.used { | |||
| 181 | + | return | |||
| 182 | + | } | |||
| 183 | + | if src != nil { | |||
| 184 | + | src.used = true | |||
| 185 | + | } | |||
| 186 | + | if !s.maySet(c) { | |||
| 187 | + | if src != nil { | |||
| 188 | + | src.cancel() | |||
| 189 | + | } | |||
| 190 | + | return | |||
| 191 | + | } | |||
| 192 | + | s.setSelection(kind, src) | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | // receive hands the reader's pipe to the source if it is still the | |||
| 196 | + | // selection; the fd is always closed here (the event dups it). | |||
| 197 | + | func (s *Server) receive(kind int, src *clipSource, mime string, fd int) { | |||
| 198 | + | defer unix.Close(fd) | |||
| 199 | + | if s.selections[kind] != src || !src.alive() { | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | for _, m := range src.mimes { | |||
| 203 | + | if m == mime { | |||
| 204 | + | src.send(mime, fd) | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // wl_data_device_manager | |||
| 211 | + | ||||
| 212 | + | type dataManager struct{ server *Server } | |||
| 213 | + | ||||
| 214 | + | func (m dataManager) CreateDataSource(r *wayland.DataDeviceManager, id uint32) { | |||
| 215 | + | src := &clipSource{client: r.Client()} | |||
| 216 | + | res, err := wayland.NewDataSource(r.Client(), r.Version(), id, dataSource{src}) | |||
| 217 | + | if err != nil { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | src.send = func(mime string, fd int) { res.SendSend(mime, fd) } | |||
| 221 | + | src.cancel = res.SendCancelled | |||
| 222 | + | src.alive = res.Resource.Alive | |||
| 223 | + | m.server.dataSources[res.Resource] = src | |||
| 224 | + | res.OnDestroy = func() { | |||
| 225 | + | delete(m.server.dataSources, res.Resource) | |||
| 226 | + | m.server.sourceGone(src) | |||
| 227 | + | } | |||
| 228 | + | } | |||
| 229 | + | func (m dataManager) GetDataDevice(r *wayland.DataDeviceManager, id uint32, _ *wayland.Seat) { | |||
| 230 | + | s := m.server | |||
| 231 | + | res, err := wayland.NewDataDevice(r.Client(), r.Version(), id, dataDevice{s}) | |||
| 232 | + | if err != nil { | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selClipboard: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) { | |||
| 236 | + | if src == nil { | |||
| 237 | + | res.SendSelection(nil) | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | offer, err := wayland.NewDataOffer(res.Client(), res.Version(), 0, dataOffer{s, src}) | |||
| 241 | + | if err != nil { | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | res.SendDataOffer(offer) | |||
| 245 | + | for _, mime := range src.mimes { | |||
| 246 | + | offer.SendOffer(mime) | |||
| 247 | + | } | |||
| 248 | + | res.SendSelection(offer) | |||
| 249 | + | }}) | |||
| 250 | + | } | |||
| 251 | + | func (dataManager) Release(r *wayland.DataDeviceManager) { r.Destroy() } | |||
| 252 | + | ||||
| 253 | + | type dataSource struct{ src *clipSource } | |||
| 254 | + | ||||
| 255 | + | func (d dataSource) Offer(_ *wayland.DataSource, mime string) { d.src.addMime(mime) } | |||
| 256 | + | func (dataSource) Destroy(*wayland.DataSource) {} | |||
| 257 | + | func (dataSource) SetActions(*wayland.DataSource, uint32) {} | |||
| 258 | + | ||||
| 259 | + | type dataDevice struct{ server *Server } | |||
| 260 | + | ||||
| 261 | + | // StartDrag is not implemented: the drag is cancelled. | |||
| 262 | + | func (d dataDevice) StartDrag(_ *wayland.DataDevice, src *wayland.DataSource, _, _ *wayland.Surface, _ uint32) { | |||
| 263 | + | if src == nil { | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | cs := d.server.dataSources[src.Resource] | |||
| 267 | + | if cs == nil || cs.used { | |||
| 268 | + | // A used source is the selection or already cancelled. | |||
| 269 | + | return | |||
| 270 | + | } | |||
| 271 | + | cs.used = true | |||
| 272 | + | if src.Resource.Alive() { | |||
| 273 | + | src.SendCancelled() | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | func (d dataDevice) SetSelection(r *wayland.DataDevice, res *wayland.DataSource, _ uint32) { | |||
| 277 | + | s := d.server | |||
| 278 | + | var src *clipSource | |||
| 279 | + | if res != nil { | |||
| 280 | + | src = s.dataSources[res.Resource] | |||
| 281 | + | if src == nil { | |||
| 282 | + | return | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | s.setFocused(r.Client(), selClipboard, src) | |||
| 286 | + | } | |||
| 287 | + | func (dataDevice) Release(r *wayland.DataDevice) { r.Destroy() } | |||
| 288 | + | ||||
| 289 | + | type dataOffer struct { | |||
| 290 | + | server *Server | |||
| 291 | + | src *clipSource | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (dataOffer) Accept(*wayland.DataOffer, uint32, string) {} | |||
| 295 | + | func (o dataOffer) Receive(_ *wayland.DataOffer, mime string, fd int) { | |||
| 296 | + | o.server.receive(selClipboard, o.src, mime, fd) | |||
| 297 | + | } | |||
| 298 | + | func (dataOffer) Destroy(*wayland.DataOffer) {} | |||
| 299 | + | func (dataOffer) Finish(*wayland.DataOffer) {} | |||
| 300 | + | func (dataOffer) SetActions(*wayland.DataOffer, uint32, uint32) {} | |||
| 301 | + | ||||
| 302 | + | // zwp_primary_selection_device_manager_v1 | |||
| 303 | + | ||||
| 304 | + | type primaryManager struct{ server *Server } | |||
| 305 | + | ||||
| 306 | + | func (m primaryManager) CreateSource(r *primaryselection.ZwpPrimarySelectionDeviceManagerV1, id uint32) { | |||
| 307 | + | src := &clipSource{client: r.Client()} | |||
| 308 | + | res, err := primaryselection.NewZwpPrimarySelectionSourceV1(r.Client(), r.Version(), id, primarySource{src}) | |||
| 309 | + | if err != nil { | |||
| 310 | + | return | |||
| 311 | + | } | |||
| 312 | + | src.send = func(mime string, fd int) { res.SendSend(mime, fd) } | |||
| 313 | + | src.cancel = res.SendCancelled | |||
| 314 | + | src.alive = res.Resource.Alive | |||
| 315 | + | m.server.primarySources[res.Resource] = src | |||
| 316 | + | res.OnDestroy = func() { | |||
| 317 | + | delete(m.server.primarySources, res.Resource) | |||
| 318 | + | m.server.sourceGone(src) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | func (m primaryManager) GetDevice(r *primaryselection.ZwpPrimarySelectionDeviceManagerV1, id uint32, _ *wayland.Seat) { | |||
| 322 | + | s := m.server | |||
| 323 | + | res, err := primaryselection.NewZwpPrimarySelectionDeviceV1(r.Client(), r.Version(), id, primaryDevice{s}) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | s.addClipDevice(&clipDevice{client: r.Client(), kinds: [2]bool{selPrimary: true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) { | |||
| 328 | + | if src == nil { | |||
| 329 | + | res.SendSelection(nil) | |||
| 330 | + | return | |||
| 331 | + | } | |||
| 332 | + | offer, err := primaryselection.NewZwpPrimarySelectionOfferV1(res.Client(), res.Version(), 0, primaryOffer{s, src}) | |||
| 333 | + | if err != nil { | |||
| 334 | + | return | |||
| 335 | + | } | |||
| 336 | + | res.SendDataOffer(offer) | |||
| 337 | + | for _, mime := range src.mimes { | |||
| 338 | + | offer.SendOffer(mime) | |||
| 339 | + | } | |||
| 340 | + | res.SendSelection(offer) | |||
| 341 | + | }}) | |||
| 342 | + | } | |||
| 343 | + | func (primaryManager) Destroy(*primaryselection.ZwpPrimarySelectionDeviceManagerV1) {} | |||
| 344 | + | ||||
| 345 | + | type primarySource struct{ src *clipSource } | |||
| 346 | + | ||||
| 347 | + | func (p primarySource) Offer(_ *primaryselection.ZwpPrimarySelectionSourceV1, mime string) { | |||
| 348 | + | p.src.addMime(mime) | |||
| 349 | + | } | |||
| 350 | + | func (primarySource) Destroy(*primaryselection.ZwpPrimarySelectionSourceV1) {} | |||
| 351 | + | ||||
| 352 | + | type primaryDevice struct{ server *Server } | |||
| 353 | + | ||||
| 354 | + | func (d primaryDevice) SetSelection(r *primaryselection.ZwpPrimarySelectionDeviceV1, res *primaryselection.ZwpPrimarySelectionSourceV1, _ uint32) { | |||
| 355 | + | s := d.server | |||
| 356 | + | var src *clipSource | |||
| 357 | + | if res != nil { | |||
| 358 | + | src = s.primarySources[res.Resource] | |||
| 359 | + | if src == nil { | |||
| 360 | + | return | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | s.setFocused(r.Client(), selPrimary, src) | |||
| 364 | + | } | |||
| 365 | + | func (primaryDevice) Destroy(*primaryselection.ZwpPrimarySelectionDeviceV1) {} | |||
| 366 | + | ||||
| 367 | + | type primaryOffer struct { | |||
| 368 | + | server *Server | |||
| 369 | + | src *clipSource | |||
| 370 | + | } | |||
| 371 | + | ||||
| 372 | + | func (o primaryOffer) Receive(_ *primaryselection.ZwpPrimarySelectionOfferV1, mime string, fd int) { | |||
| 373 | + | o.server.receive(selPrimary, o.src, mime, fd) | |||
| 374 | + | } | |||
| 375 | + | func (primaryOffer) Destroy(*primaryselection.ZwpPrimarySelectionOfferV1) {} | |||
| 376 | + | ||||
| 377 | + | // ext_data_control_manager_v1 | |||
| 378 | + | ||||
| 379 | + | type controlManager struct{ server *Server } | |||
| 380 | + | ||||
| 381 | + | func (m controlManager) CreateDataSource(r *extdatacontrol.ExtDataControlManagerV1, id uint32) { | |||
| 382 | + | src := &clipSource{client: r.Client()} | |||
| 383 | + | res, err := extdatacontrol.NewExtDataControlSourceV1(r.Client(), r.Version(), id, controlSource{src}) | |||
| 384 | + | if err != nil { | |||
| 385 | + | return | |||
| 386 | + | } | |||
| 387 | + | src.send = func(mime string, fd int) { res.SendSend(mime, fd) } | |||
| 388 | + | src.cancel = res.SendCancelled | |||
| 389 | + | src.alive = res.Resource.Alive | |||
| 390 | + | m.server.controlSources[res.Resource] = src | |||
| 391 | + | res.OnDestroy = func() { | |||
| 392 | + | delete(m.server.controlSources, res.Resource) | |||
| 393 | + | m.server.sourceGone(src) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 396 | + | func (m controlManager) GetDataDevice(r *extdatacontrol.ExtDataControlManagerV1, id uint32, _ *wayland.Seat) { | |||
| 397 | + | s := m.server | |||
| 398 | + | res, err := extdatacontrol.NewExtDataControlDeviceV1(r.Client(), r.Version(), id, controlDevice{s}) | |||
| 399 | + | if err != nil { | |||
| 400 | + | return | |||
| 401 | + | } | |||
| 402 | + | s.addClipDevice(&clipDevice{client: r.Client(), control: true, kinds: [2]bool{true, true}, alive: res.Resource.Alive, offer: func(kind int, src *clipSource) { | |||
| 403 | + | send := res.SendSelection | |||
| 404 | + | if kind == selPrimary { | |||
| 405 | + | send = res.SendPrimarySelection | |||
| 406 | + | } | |||
| 407 | + | if src == nil { | |||
| 408 | + | send(nil) | |||
| 409 | + | return | |||
| 410 | + | } | |||
| 411 | + | offer, err := extdatacontrol.NewExtDataControlOfferV1(res.Client(), res.Version(), 0, controlOffer{s, kind, src}) | |||
| 412 | + | if err != nil { | |||
| 413 | + | return | |||
| 414 | + | } | |||
| 415 | + | res.SendDataOffer(offer) | |||
| 416 | + | for _, mime := range src.mimes { | |||
| 417 | + | offer.SendOffer(mime) | |||
| 418 | + | } | |||
| 419 | + | send(offer) | |||
| 420 | + | }}) | |||
| 421 | + | } | |||
| 422 | + | func (controlManager) Destroy(*extdatacontrol.ExtDataControlManagerV1) {} | |||
| 423 | + | ||||
| 424 | + | type controlSource struct{ src *clipSource } | |||
| 425 | + | ||||
| 426 | + | func (c controlSource) Offer(r *extdatacontrol.ExtDataControlSourceV1, mime string) { | |||
| 427 | + | if !c.src.addMime(mime) { | |||
| 428 | + | r.PostError(uint32(extdatacontrol.ExtDataControlSourceV1ErrorInvalidOffer), "offer after the source was used") | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | func (controlSource) Destroy(*extdatacontrol.ExtDataControlSourceV1) {} | |||
| 432 | + | ||||
| 433 | + | type controlDevice struct{ server *Server } | |||
| 434 | + | ||||
| 435 | + | func (d controlDevice) SetSelection(r *extdatacontrol.ExtDataControlDeviceV1, res *extdatacontrol.ExtDataControlSourceV1) { | |||
| 436 | + | d.set(r, selClipboard, res) | |||
| 437 | + | } | |||
| 438 | + | func (d controlDevice) SetPrimarySelection(r *extdatacontrol.ExtDataControlDeviceV1, res *extdatacontrol.ExtDataControlSourceV1) { | |||
| 439 | + | d.set(r, selPrimary, res) | |||
| 440 | + | } | |||
| 441 | + | ||||
| 442 | + | // set lets a clipboard manager set a selection without focus. A source | |||
| 443 | + | // may be used once. | |||
| 444 | + | func (d controlDevice) set(r *extdatacontrol.ExtDataControlDeviceV1, kind int, res *extdatacontrol.ExtDataControlSourceV1) { | |||
| 445 | + | s := d.server | |||
| 446 | + | var src *clipSource | |||
| 447 | + | if res != nil { | |||
| 448 | + | src = s.controlSources[res.Resource] | |||
| 449 | + | if src == nil { | |||
| 450 | + | return | |||
| 451 | + | } | |||
| 452 | + | if src.used { | |||
| 453 | + | r.PostError(uint32(extdatacontrol.ExtDataControlDeviceV1ErrorUsedSource), "source already used") | |||
| 454 | + | return | |||
| 455 | + | } | |||
| 456 | + | src.used = true | |||
| 457 | + | } | |||
| 458 | + | s.setSelection(kind, src) | |||
| 459 | + | } | |||
| 460 | + | func (controlDevice) Destroy(*extdatacontrol.ExtDataControlDeviceV1) {} | |||
| 461 | + | ||||
| 462 | + | type controlOffer struct { | |||
| 463 | + | server *Server | |||
| 464 | + | kind int | |||
| 465 | + | src *clipSource | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | func (o controlOffer) Receive(_ *extdatacontrol.ExtDataControlOfferV1, mime string, fd int) { | |||
| 469 | + | o.server.receive(o.kind, o.src, mime, fd) | |||
| 470 | + | } | |||
| 471 | + | func (controlOffer) Destroy(*extdatacontrol.ExtDataControlOfferV1) {} | |||
A internal/adapters/wayland/clipboard_test.go +360 -0
| @@ -0,0 +1,360 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |
| 8 | + | "github.com/bnema/purego-libwayland/protocol/extdatacontrol" | |
| 9 | + | "github.com/bnema/purego-libwayland/protocol/primaryselection" | |
| 10 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 11 | + | "github.com/bnema/wlturbo" | |
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // clipEvents records the selection offers sent to a data device. | |
| 16 | + | type clipEvents struct { | |
| 17 | + | wlturbo.BaseProxy | |
| 18 | + | c *wlturbo.Display | |
| 19 | + | offers chan uint32 // selection offer ids; 0 clears | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (p *clipEvents) Dispatch(e *wlturbo.Event) { | |
| 23 | + | switch e.Opcode { | |
| 24 | + | case uint16(wayland.DataDeviceEventDataOffer): | |
| 25 | + | // The new offer: register it so its offer events are accepted. | |
| 26 | + | registerOffer(p.c, e.Uint32()) | |
| 27 | + | case uint16(wayland.DataDeviceEventSelection): | |
| 28 | + | p.offers <- e.Uint32() | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type sourceEvents struct { | |
| 33 | + | wlturbo.BaseProxy | |
| 34 | + | sends chan int | |
| 35 | + | cancelled chan struct{} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (p *sourceEvents) Dispatch(e *wlturbo.Event) { | |
| 39 | + | switch e.Opcode { | |
| 40 | + | case uint16(wayland.DataSourceEventSend): | |
| 41 | + | _ = e.String() | |
| 42 | + | fd := int(e.Fd()) | |
| 43 | + | _, _ = unix.Write(fd, []byte("pasted")) | |
| 44 | + | _ = unix.Close(fd) | |
| 45 | + | p.sends <- fd | |
| 46 | + | case uint16(wayland.DataSourceEventCancelled): | |
| 47 | + | p.cancelled <- struct{}{} | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func registerOffer(c *wlturbo.Display, id uint32) { | |
| 52 | + | p := &protocolProxy{} | |
| 53 | + | p.SetID(id) | |
| 54 | + | c.Context().Register(p) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // clipClient is a client with a toplevel, a data device and a pointer to | |
| 58 | + | // its clipboard globals. | |
| 59 | + | type clipClient struct { | |
| 60 | + | c *wlturbo.Display | |
| 61 | + | manager uint32 | |
| 62 | + | device *clipEvents | |
| 63 | + | window ports.WindowID | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func newClipClient(t *testing.T, s *Server, dir string, events chan ports.ClientEvent) *clipClient { | |
| 67 | + | t.Helper() | |
| 68 | + | c := protocolClient(t, s, dir) | |
| 69 | + | seat := bindProtocol(t, c, "wl_seat") | |
| 70 | + | registerProtocol(t, c, seat) | |
| 71 | + | g, _ := c.Registry().FindGlobal("wl_data_device_manager") | |
| 72 | + | manager, err := c.Registry().BindID(g.Name, g.Interface, 3) | |
| 73 | + | if err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | device := &clipEvents{c: c, offers: make(chan uint32, 8)} | |
| 77 | + | device.SetID(c.AllocateID()) | |
| 78 | + | c.Context().Register(device) | |
| 79 | + | requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat) | |
| 80 | + | w := toplevelMapper(t, c, events)() | |
| 81 | + | return &clipClient{c: c, manager: manager, device: device, window: w.ID} | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // selection returns the next selection offer id sent to the client. | |
| 85 | + | func (cc *clipClient) selection(t *testing.T) (uint32, bool) { | |
| 86 | + | t.Helper() | |
| 87 | + | if err := cc.c.Roundtrip(); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | select { | |
| 91 | + | case id := <-cc.device.offers: | |
| 92 | + | return id, true | |
| 93 | + | case <-time.After(100 * time.Millisecond): | |
| 94 | + | return 0, false | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (cc *clipClient) copy(t *testing.T) *sourceEvents { | |
| 99 | + | t.Helper() | |
| 100 | + | src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)} | |
| 101 | + | src.SetID(cc.c.AllocateID()) | |
| 102 | + | cc.c.Context().Register(src) | |
| 103 | + | requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID()) | |
| 104 | + | requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain") | |
| 105 | + | requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0)) | |
| 106 | + | if err := cc.c.Roundtrip(); err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | return src | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // focus gives cc keyboard focus and waits for the selection it gets then. | |
| 113 | + | func (cc *clipClient) focus(t *testing.T, commands chan<- ports.ClientCommand) uint32 { | |
| 114 | + | t.Helper() | |
| 115 | + | commands <- ports.FocusWindow{ID: cc.window} | |
| 116 | + | deadline := time.After(2 * time.Second) | |
| 117 | + | for { | |
| 118 | + | if id, ok := cc.selection(t); ok { | |
| 119 | + | return id | |
| 120 | + | } | |
| 121 | + | select { | |
| 122 | + | case <-deadline: | |
| 123 | + | t.Fatal("no selection on focus") | |
| 124 | + | default: | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // The clipboard follows keyboard focus: only the focused client may set | |
| 130 | + | // it, and a client gets it when it gains focus; pasting reaches the source. | |
| 131 | + | func TestClipboardFollowsFocus(t *testing.T) { | |
| 132 | + | s, events, commands, dir := keyboardServer(t) | |
| 133 | + | a := newClipClient(t, s, dir, events) | |
| 134 | + | b := newClipClient(t, s, dir, events) | |
| 135 | + | a.focus(t, commands) | |
| 136 | + | ||
| 137 | + | // An unfocused client cannot set the selection: its source is cancelled. | |
| 138 | + | stolen := b.copy(t) | |
| 139 | + | select { | |
| 140 | + | case <-stolen.cancelled: | |
| 141 | + | case <-time.After(2 * time.Second): | |
| 142 | + | t.Fatal("unfocused source not cancelled") | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | src := a.copy(t) | |
| 146 | + | offer, ok := a.selection(t) | |
| 147 | + | if !ok || offer == 0 { | |
| 148 | + | t.Fatalf("focused client got no offer: %d %v", offer, ok) | |
| 149 | + | } | |
| 150 | + | if id, ok := b.selection(t); ok { | |
| 151 | + | t.Fatalf("unfocused client got selection %d", id) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // Focus moves to b: b gets the offer and can paste from a. | |
| 155 | + | if offer = b.focus(t, commands); offer == 0 { | |
| 156 | + | t.Fatal("no offer on focus") | |
| 157 | + | } | |
| 158 | + | var fds [2]int | |
| 159 | + | if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | defer unix.Close(fds[0]) | |
| 163 | + | if err := b.c.SendRequestWithFDs(offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | _ = unix.Close(fds[1]) | |
| 167 | + | if err := b.c.Roundtrip(); err != nil { | |
| 168 | + | t.Fatal(err) | |
| 169 | + | } | |
| 170 | + | // a answers the send event. | |
| 171 | + | deadline := time.After(2 * time.Second) | |
| 172 | + | for len(src.sends) == 0 { | |
| 173 | + | if err := a.c.Roundtrip(); err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | select { | |
| 177 | + | case <-deadline: | |
| 178 | + | t.Fatal("source never asked to send") | |
| 179 | + | case <-time.After(5 * time.Millisecond): | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | buf := make([]byte, 16) | |
| 183 | + | n, _ := unix.Read(fds[0], buf) | |
| 184 | + | if string(buf[:n]) != "pasted" { | |
| 185 | + | t.Fatalf("pasted %q", buf[:n]) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // A clipboard manager (ext_data_control_v1) sees the selection without | |
| 190 | + | // focus, and a destroyed source clears it. | |
| 191 | + | func TestDataControlSeesSelection(t *testing.T) { | |
| 192 | + | s, events, commands, dir := keyboardServer(t) | |
| 193 | + | a := newClipClient(t, s, dir, events) | |
| 194 | + | a.focus(t, commands) | |
| 195 | + | ||
| 196 | + | m := protocolClient(t, s, dir) | |
| 197 | + | seat := bindProtocol(t, m, "wl_seat") | |
| 198 | + | registerProtocol(t, m, seat) | |
| 199 | + | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | |
| 200 | + | offers := make(chan uint32, 8) | |
| 201 | + | dev := &controlEvents{c: m, offers: offers} | |
| 202 | + | dev.SetID(m.AllocateID()) | |
| 203 | + | m.Context().Register(dev) | |
| 204 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | |
| 205 | + | next := func() uint32 { | |
| 206 | + | t.Helper() | |
| 207 | + | deadline := time.After(2 * time.Second) | |
| 208 | + | for { | |
| 209 | + | if err := m.Roundtrip(); err != nil { | |
| 210 | + | t.Fatal(err) | |
| 211 | + | } | |
| 212 | + | select { | |
| 213 | + | case id := <-offers: | |
| 214 | + | return id | |
| 215 | + | case <-deadline: | |
| 216 | + | t.Fatal("no selection event") | |
| 217 | + | case <-time.After(5 * time.Millisecond): | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | if id := next(); id != 0 { | |
| 222 | + | t.Fatalf("initial selection %d, want none", id) | |
| 223 | + | } | |
| 224 | + | src := a.copy(t) | |
| 225 | + | if id := next(); id == 0 { | |
| 226 | + | t.Fatal("manager did not see the copy") | |
| 227 | + | } | |
| 228 | + | requestProtocol(t, a.c, src.ID(), wayland.DataSourceRequestDestroy) | |
| 229 | + | if err := a.c.Roundtrip(); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | if id := next(); id != 0 { | |
| 233 | + | t.Fatalf("selection %d after its source died", id) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | type controlEvents struct { | |
| 238 | + | wlturbo.BaseProxy | |
| 239 | + | c *wlturbo.Display | |
| 240 | + | offers chan uint32 | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | func (p *controlEvents) Dispatch(e *wlturbo.Event) { | |
| 244 | + | switch e.Opcode { | |
| 245 | + | case uint16(extdatacontrol.ExtDataControlDeviceV1EventDataOffer): | |
| 246 | + | registerOffer(p.c, e.Uint32()) | |
| 247 | + | case uint16(extdatacontrol.ExtDataControlDeviceV1EventSelection): | |
| 248 | + | p.offers <- e.Uint32() | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // A new copy cancels the previous source; setting the current source | |
| 253 | + | // again changes nothing. | |
| 254 | + | func TestClipboardReplaceCancelsOldSource(t *testing.T) { | |
| 255 | + | s, events, commands, dir := keyboardServer(t) | |
| 256 | + | a := newClipClient(t, s, dir, events) | |
| 257 | + | a.focus(t, commands) | |
| 258 | + | first := a.copy(t) | |
| 259 | + | if id, ok := a.selection(t); !ok || id == 0 { | |
| 260 | + | t.Fatal("no offer for the first copy") | |
| 261 | + | } | |
| 262 | + | requestProtocol(t, a.c, a.device.ID(), wayland.DataDeviceRequestSetSelection, first.ID(), uint32(0)) | |
| 263 | + | if id, ok := a.selection(t); ok { | |
| 264 | + | t.Fatalf("setting the same source again sent offer %d", id) | |
| 265 | + | } | |
| 266 | + | if len(first.cancelled) != 0 { | |
| 267 | + | t.Fatal("current source cancelled when set again") | |
| 268 | + | } | |
| 269 | + | a.copy(t) | |
| 270 | + | if err := a.c.Roundtrip(); err != nil { | |
| 271 | + | t.Fatal(err) | |
| 272 | + | } | |
| 273 | + | select { | |
| 274 | + | case <-first.cancelled: | |
| 275 | + | case <-time.After(2 * time.Second): | |
| 276 | + | t.Fatal("replaced source not cancelled") | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // A data-control source can be set once. | |
| 281 | + | func TestDataControlUsedSource(t *testing.T) { | |
| 282 | + | s, _, _, dir := keyboardServer(t) | |
| 283 | + | m := protocolClient(t, s, dir) | |
| 284 | + | seat := bindProtocol(t, m, "wl_seat") | |
| 285 | + | registerProtocol(t, m, seat) | |
| 286 | + | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | |
| 287 | + | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | |
| 288 | + | dev.SetID(m.AllocateID()) | |
| 289 | + | m.Context().Register(dev) | |
| 290 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | |
| 291 | + | src := m.AllocateID() | |
| 292 | + | registerProtocol(t, m, src) | |
| 293 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestCreateDataSource, src) | |
| 294 | + | requestProtocol(t, m, src, extdatacontrol.ExtDataControlSourceV1RequestOffer, "text/plain") | |
| 295 | + | requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetSelection, src) | |
| 296 | + | requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetPrimarySelection, src) | |
| 297 | + | expectProtocolError(t, m, dev.ID(), uint32(extdatacontrol.ExtDataControlDeviceV1ErrorUsedSource)) | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | type primaryEvents struct { | |
| 301 | + | wlturbo.BaseProxy | |
| 302 | + | c *wlturbo.Display | |
| 303 | + | offers chan uint32 | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | func (p *primaryEvents) Dispatch(e *wlturbo.Event) { | |
| 307 | + | switch e.Opcode { | |
| 308 | + | case uint16(primaryselection.ZwpPrimarySelectionDeviceV1EventDataOffer): | |
| 309 | + | registerOffer(p.c, e.Uint32()) | |
| 310 | + | case uint16(primaryselection.ZwpPrimarySelectionDeviceV1EventSelection): | |
| 311 | + | p.offers <- e.Uint32() | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // A clipboard manager setting the primary selection reaches the focused | |
| 316 | + | // client's primary selection device. | |
| 317 | + | func TestPrimarySelectionFromManager(t *testing.T) { | |
| 318 | + | s, events, commands, dir := keyboardServer(t) | |
| 319 | + | a := newClipClient(t, s, dir, events) | |
| 320 | + | pm := bindProtocol(t, a.c, "zwp_primary_selection_device_manager_v1") | |
| 321 | + | seat := bindProtocol(t, a.c, "wl_seat") | |
| 322 | + | registerProtocol(t, a.c, seat) | |
| 323 | + | pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)} | |
| 324 | + | pdev.SetID(a.c.AllocateID()) | |
| 325 | + | a.c.Context().Register(pdev) | |
| 326 | + | requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat) | |
| 327 | + | a.focus(t, commands) | |
| 328 | + | ||
| 329 | + | m := protocolClient(t, s, dir) | |
| 330 | + | mseat := bindProtocol(t, m, "wl_seat") | |
| 331 | + | registerProtocol(t, m, mseat) | |
| 332 | + | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | |
| 333 | + | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | |
| 334 | + | dev.SetID(m.AllocateID()) | |
| 335 | + | m.Context().Register(dev) | |
| 336 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat) | |
| 337 | + | src := m.AllocateID() | |
| 338 | + | registerProtocol(t, m, src) | |
| 339 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestCreateDataSource, src) | |
| 340 | + | requestProtocol(t, m, src, extdatacontrol.ExtDataControlSourceV1RequestOffer, "text/plain") | |
| 341 | + | requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetPrimarySelection, src) | |
| 342 | + | if err := m.Roundtrip(); err != nil { | |
| 343 | + | t.Fatal(err) | |
| 344 | + | } | |
| 345 | + | deadline := time.After(2 * time.Second) | |
| 346 | + | for { | |
| 347 | + | if err := a.c.Roundtrip(); err != nil { | |
| 348 | + | t.Fatal(err) | |
| 349 | + | } | |
| 350 | + | select { | |
| 351 | + | case id := <-pdev.offers: | |
| 352 | + | if id != 0 { | |
| 353 | + | return | |
| 354 | + | } | |
| 355 | + | case <-deadline: | |
| 356 | + | t.Fatal("focused client got no primary selection") | |
| 357 | + | case <-time.After(5 * time.Millisecond): | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | } | |
| @@ -0,0 +1,360 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 8 | + | "github.com/bnema/purego-libwayland/protocol/extdatacontrol" | |||
| 9 | + | "github.com/bnema/purego-libwayland/protocol/primaryselection" | |||
| 10 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 11 | + | "github.com/bnema/wlturbo" | |||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // clipEvents records the selection offers sent to a data device. | |||
| 16 | + | type clipEvents struct { | |||
| 17 | + | wlturbo.BaseProxy | |||
| 18 | + | c *wlturbo.Display | |||
| 19 | + | offers chan uint32 // selection offer ids; 0 clears | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (p *clipEvents) Dispatch(e *wlturbo.Event) { | |||
| 23 | + | switch e.Opcode { | |||
| 24 | + | case uint16(wayland.DataDeviceEventDataOffer): | |||
| 25 | + | // The new offer: register it so its offer events are accepted. | |||
| 26 | + | registerOffer(p.c, e.Uint32()) | |||
| 27 | + | case uint16(wayland.DataDeviceEventSelection): | |||
| 28 | + | p.offers <- e.Uint32() | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type sourceEvents struct { | |||
| 33 | + | wlturbo.BaseProxy | |||
| 34 | + | sends chan int | |||
| 35 | + | cancelled chan struct{} | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (p *sourceEvents) Dispatch(e *wlturbo.Event) { | |||
| 39 | + | switch e.Opcode { | |||
| 40 | + | case uint16(wayland.DataSourceEventSend): | |||
| 41 | + | _ = e.String() | |||
| 42 | + | fd := int(e.Fd()) | |||
| 43 | + | _, _ = unix.Write(fd, []byte("pasted")) | |||
| 44 | + | _ = unix.Close(fd) | |||
| 45 | + | p.sends <- fd | |||
| 46 | + | case uint16(wayland.DataSourceEventCancelled): | |||
| 47 | + | p.cancelled <- struct{}{} | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func registerOffer(c *wlturbo.Display, id uint32) { | |||
| 52 | + | p := &protocolProxy{} | |||
| 53 | + | p.SetID(id) | |||
| 54 | + | c.Context().Register(p) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // clipClient is a client with a toplevel, a data device and a pointer to | |||
| 58 | + | // its clipboard globals. | |||
| 59 | + | type clipClient struct { | |||
| 60 | + | c *wlturbo.Display | |||
| 61 | + | manager uint32 | |||
| 62 | + | device *clipEvents | |||
| 63 | + | window ports.WindowID | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func newClipClient(t *testing.T, s *Server, dir string, events chan ports.ClientEvent) *clipClient { | |||
| 67 | + | t.Helper() | |||
| 68 | + | c := protocolClient(t, s, dir) | |||
| 69 | + | seat := bindProtocol(t, c, "wl_seat") | |||
| 70 | + | registerProtocol(t, c, seat) | |||
| 71 | + | g, _ := c.Registry().FindGlobal("wl_data_device_manager") | |||
| 72 | + | manager, err := c.Registry().BindID(g.Name, g.Interface, 3) | |||
| 73 | + | if err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | device := &clipEvents{c: c, offers: make(chan uint32, 8)} | |||
| 77 | + | device.SetID(c.AllocateID()) | |||
| 78 | + | c.Context().Register(device) | |||
| 79 | + | requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat) | |||
| 80 | + | w := toplevelMapper(t, c, events)() | |||
| 81 | + | return &clipClient{c: c, manager: manager, device: device, window: w.ID} | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // selection returns the next selection offer id sent to the client. | |||
| 85 | + | func (cc *clipClient) selection(t *testing.T) (uint32, bool) { | |||
| 86 | + | t.Helper() | |||
| 87 | + | if err := cc.c.Roundtrip(); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | select { | |||
| 91 | + | case id := <-cc.device.offers: | |||
| 92 | + | return id, true | |||
| 93 | + | case <-time.After(100 * time.Millisecond): | |||
| 94 | + | return 0, false | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | func (cc *clipClient) copy(t *testing.T) *sourceEvents { | |||
| 99 | + | t.Helper() | |||
| 100 | + | src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)} | |||
| 101 | + | src.SetID(cc.c.AllocateID()) | |||
| 102 | + | cc.c.Context().Register(src) | |||
| 103 | + | requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID()) | |||
| 104 | + | requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain") | |||
| 105 | + | requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0)) | |||
| 106 | + | if err := cc.c.Roundtrip(); err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | return src | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // focus gives cc keyboard focus and waits for the selection it gets then. | |||
| 113 | + | func (cc *clipClient) focus(t *testing.T, commands chan<- ports.ClientCommand) uint32 { | |||
| 114 | + | t.Helper() | |||
| 115 | + | commands <- ports.FocusWindow{ID: cc.window} | |||
| 116 | + | deadline := time.After(2 * time.Second) | |||
| 117 | + | for { | |||
| 118 | + | if id, ok := cc.selection(t); ok { | |||
| 119 | + | return id | |||
| 120 | + | } | |||
| 121 | + | select { | |||
| 122 | + | case <-deadline: | |||
| 123 | + | t.Fatal("no selection on focus") | |||
| 124 | + | default: | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | // The clipboard follows keyboard focus: only the focused client may set | |||
| 130 | + | // it, and a client gets it when it gains focus; pasting reaches the source. | |||
| 131 | + | func TestClipboardFollowsFocus(t *testing.T) { | |||
| 132 | + | s, events, commands, dir := keyboardServer(t) | |||
| 133 | + | a := newClipClient(t, s, dir, events) | |||
| 134 | + | b := newClipClient(t, s, dir, events) | |||
| 135 | + | a.focus(t, commands) | |||
| 136 | + | ||||
| 137 | + | // An unfocused client cannot set the selection: its source is cancelled. | |||
| 138 | + | stolen := b.copy(t) | |||
| 139 | + | select { | |||
| 140 | + | case <-stolen.cancelled: | |||
| 141 | + | case <-time.After(2 * time.Second): | |||
| 142 | + | t.Fatal("unfocused source not cancelled") | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | src := a.copy(t) | |||
| 146 | + | offer, ok := a.selection(t) | |||
| 147 | + | if !ok || offer == 0 { | |||
| 148 | + | t.Fatalf("focused client got no offer: %d %v", offer, ok) | |||
| 149 | + | } | |||
| 150 | + | if id, ok := b.selection(t); ok { | |||
| 151 | + | t.Fatalf("unfocused client got selection %d", id) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // Focus moves to b: b gets the offer and can paste from a. | |||
| 155 | + | if offer = b.focus(t, commands); offer == 0 { | |||
| 156 | + | t.Fatal("no offer on focus") | |||
| 157 | + | } | |||
| 158 | + | var fds [2]int | |||
| 159 | + | if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | defer unix.Close(fds[0]) | |||
| 163 | + | if err := b.c.SendRequestWithFDs(offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | _ = unix.Close(fds[1]) | |||
| 167 | + | if err := b.c.Roundtrip(); err != nil { | |||
| 168 | + | t.Fatal(err) | |||
| 169 | + | } | |||
| 170 | + | // a answers the send event. | |||
| 171 | + | deadline := time.After(2 * time.Second) | |||
| 172 | + | for len(src.sends) == 0 { | |||
| 173 | + | if err := a.c.Roundtrip(); err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | select { | |||
| 177 | + | case <-deadline: | |||
| 178 | + | t.Fatal("source never asked to send") | |||
| 179 | + | case <-time.After(5 * time.Millisecond): | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | buf := make([]byte, 16) | |||
| 183 | + | n, _ := unix.Read(fds[0], buf) | |||
| 184 | + | if string(buf[:n]) != "pasted" { | |||
| 185 | + | t.Fatalf("pasted %q", buf[:n]) | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // A clipboard manager (ext_data_control_v1) sees the selection without | |||
| 190 | + | // focus, and a destroyed source clears it. | |||
| 191 | + | func TestDataControlSeesSelection(t *testing.T) { | |||
| 192 | + | s, events, commands, dir := keyboardServer(t) | |||
| 193 | + | a := newClipClient(t, s, dir, events) | |||
| 194 | + | a.focus(t, commands) | |||
| 195 | + | ||||
| 196 | + | m := protocolClient(t, s, dir) | |||
| 197 | + | seat := bindProtocol(t, m, "wl_seat") | |||
| 198 | + | registerProtocol(t, m, seat) | |||
| 199 | + | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | |||
| 200 | + | offers := make(chan uint32, 8) | |||
| 201 | + | dev := &controlEvents{c: m, offers: offers} | |||
| 202 | + | dev.SetID(m.AllocateID()) | |||
| 203 | + | m.Context().Register(dev) | |||
| 204 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | |||
| 205 | + | next := func() uint32 { | |||
| 206 | + | t.Helper() | |||
| 207 | + | deadline := time.After(2 * time.Second) | |||
| 208 | + | for { | |||
| 209 | + | if err := m.Roundtrip(); err != nil { | |||
| 210 | + | t.Fatal(err) | |||
| 211 | + | } | |||
| 212 | + | select { | |||
| 213 | + | case id := <-offers: | |||
| 214 | + | return id | |||
| 215 | + | case <-deadline: | |||
| 216 | + | t.Fatal("no selection event") | |||
| 217 | + | case <-time.After(5 * time.Millisecond): | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | if id := next(); id != 0 { | |||
| 222 | + | t.Fatalf("initial selection %d, want none", id) | |||
| 223 | + | } | |||
| 224 | + | src := a.copy(t) | |||
| 225 | + | if id := next(); id == 0 { | |||
| 226 | + | t.Fatal("manager did not see the copy") | |||
| 227 | + | } | |||
| 228 | + | requestProtocol(t, a.c, src.ID(), wayland.DataSourceRequestDestroy) | |||
| 229 | + | if err := a.c.Roundtrip(); err != nil { | |||
| 230 | + | t.Fatal(err) | |||
| 231 | + | } | |||
| 232 | + | if id := next(); id != 0 { | |||
| 233 | + | t.Fatalf("selection %d after its source died", id) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | type controlEvents struct { | |||
| 238 | + | wlturbo.BaseProxy | |||
| 239 | + | c *wlturbo.Display | |||
| 240 | + | offers chan uint32 | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | func (p *controlEvents) Dispatch(e *wlturbo.Event) { | |||
| 244 | + | switch e.Opcode { | |||
| 245 | + | case uint16(extdatacontrol.ExtDataControlDeviceV1EventDataOffer): | |||
| 246 | + | registerOffer(p.c, e.Uint32()) | |||
| 247 | + | case uint16(extdatacontrol.ExtDataControlDeviceV1EventSelection): | |||
| 248 | + | p.offers <- e.Uint32() | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // A new copy cancels the previous source; setting the current source | |||
| 253 | + | // again changes nothing. | |||
| 254 | + | func TestClipboardReplaceCancelsOldSource(t *testing.T) { | |||
| 255 | + | s, events, commands, dir := keyboardServer(t) | |||
| 256 | + | a := newClipClient(t, s, dir, events) | |||
| 257 | + | a.focus(t, commands) | |||
| 258 | + | first := a.copy(t) | |||
| 259 | + | if id, ok := a.selection(t); !ok || id == 0 { | |||
| 260 | + | t.Fatal("no offer for the first copy") | |||
| 261 | + | } | |||
| 262 | + | requestProtocol(t, a.c, a.device.ID(), wayland.DataDeviceRequestSetSelection, first.ID(), uint32(0)) | |||
| 263 | + | if id, ok := a.selection(t); ok { | |||
| 264 | + | t.Fatalf("setting the same source again sent offer %d", id) | |||
| 265 | + | } | |||
| 266 | + | if len(first.cancelled) != 0 { | |||
| 267 | + | t.Fatal("current source cancelled when set again") | |||
| 268 | + | } | |||
| 269 | + | a.copy(t) | |||
| 270 | + | if err := a.c.Roundtrip(); err != nil { | |||
| 271 | + | t.Fatal(err) | |||
| 272 | + | } | |||
| 273 | + | select { | |||
| 274 | + | case <-first.cancelled: | |||
| 275 | + | case <-time.After(2 * time.Second): | |||
| 276 | + | t.Fatal("replaced source not cancelled") | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // A data-control source can be set once. | |||
| 281 | + | func TestDataControlUsedSource(t *testing.T) { | |||
| 282 | + | s, _, _, dir := keyboardServer(t) | |||
| 283 | + | m := protocolClient(t, s, dir) | |||
| 284 | + | seat := bindProtocol(t, m, "wl_seat") | |||
| 285 | + | registerProtocol(t, m, seat) | |||
| 286 | + | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | |||
| 287 | + | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | |||
| 288 | + | dev.SetID(m.AllocateID()) | |||
| 289 | + | m.Context().Register(dev) | |||
| 290 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat) | |||
| 291 | + | src := m.AllocateID() | |||
| 292 | + | registerProtocol(t, m, src) | |||
| 293 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestCreateDataSource, src) | |||
| 294 | + | requestProtocol(t, m, src, extdatacontrol.ExtDataControlSourceV1RequestOffer, "text/plain") | |||
| 295 | + | requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetSelection, src) | |||
| 296 | + | requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetPrimarySelection, src) | |||
| 297 | + | expectProtocolError(t, m, dev.ID(), uint32(extdatacontrol.ExtDataControlDeviceV1ErrorUsedSource)) | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | type primaryEvents struct { | |||
| 301 | + | wlturbo.BaseProxy | |||
| 302 | + | c *wlturbo.Display | |||
| 303 | + | offers chan uint32 | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | func (p *primaryEvents) Dispatch(e *wlturbo.Event) { | |||
| 307 | + | switch e.Opcode { | |||
| 308 | + | case uint16(primaryselection.ZwpPrimarySelectionDeviceV1EventDataOffer): | |||
| 309 | + | registerOffer(p.c, e.Uint32()) | |||
| 310 | + | case uint16(primaryselection.ZwpPrimarySelectionDeviceV1EventSelection): | |||
| 311 | + | p.offers <- e.Uint32() | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // A clipboard manager setting the primary selection reaches the focused | |||
| 316 | + | // client's primary selection device. | |||
| 317 | + | func TestPrimarySelectionFromManager(t *testing.T) { | |||
| 318 | + | s, events, commands, dir := keyboardServer(t) | |||
| 319 | + | a := newClipClient(t, s, dir, events) | |||
| 320 | + | pm := bindProtocol(t, a.c, "zwp_primary_selection_device_manager_v1") | |||
| 321 | + | seat := bindProtocol(t, a.c, "wl_seat") | |||
| 322 | + | registerProtocol(t, a.c, seat) | |||
| 323 | + | pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)} | |||
| 324 | + | pdev.SetID(a.c.AllocateID()) | |||
| 325 | + | a.c.Context().Register(pdev) | |||
| 326 | + | requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat) | |||
| 327 | + | a.focus(t, commands) | |||
| 328 | + | ||||
| 329 | + | m := protocolClient(t, s, dir) | |||
| 330 | + | mseat := bindProtocol(t, m, "wl_seat") | |||
| 331 | + | registerProtocol(t, m, mseat) | |||
| 332 | + | manager := bindProtocol(t, m, "ext_data_control_manager_v1") | |||
| 333 | + | dev := &controlEvents{c: m, offers: make(chan uint32, 8)} | |||
| 334 | + | dev.SetID(m.AllocateID()) | |||
| 335 | + | m.Context().Register(dev) | |||
| 336 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat) | |||
| 337 | + | src := m.AllocateID() | |||
| 338 | + | registerProtocol(t, m, src) | |||
| 339 | + | requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestCreateDataSource, src) | |||
| 340 | + | requestProtocol(t, m, src, extdatacontrol.ExtDataControlSourceV1RequestOffer, "text/plain") | |||
| 341 | + | requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetPrimarySelection, src) | |||
| 342 | + | if err := m.Roundtrip(); err != nil { | |||
| 343 | + | t.Fatal(err) | |||
| 344 | + | } | |||
| 345 | + | deadline := time.After(2 * time.Second) | |||
| 346 | + | for { | |||
| 347 | + | if err := a.c.Roundtrip(); err != nil { | |||
| 348 | + | t.Fatal(err) | |||
| 349 | + | } | |||
| 350 | + | select { | |||
| 351 | + | case id := <-pdev.offers: | |||
| 352 | + | if id != 0 { | |||
| 353 | + | return | |||
| 354 | + | } | |||
| 355 | + | case <-deadline: | |||
| 356 | + | t.Fatal("focused client got no primary selection") | |||
| 357 | + | case <-time.After(5 * time.Millisecond): | |||
| 358 | + | } | |||
| 359 | + | } | |||
| 360 | + | } | |||
M internal/adapters/wayland/globals.go +1 -26
| @@ -48,9 +48,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 48 | 48 | } | |
| 49 | 49 | }) | |
| 50 | 50 | }, | |
| 51 | - | func() error { | |
| 52 | - | return wayland.NewDataDeviceManagerGlobal(d, 3, func(c server.Client, v, id uint32) { wayland.NewDataDeviceManager(c, int32(v), id, dataManager{}) }) | |
| 53 | - | }, | |
| 51 | + | func() error { return registerClipboard(d, s) }, | |
| 54 | 52 | } { | |
| 55 | 53 | if err := register(); err != nil { | |
| 56 | 54 | return err | |
| @@ -396,26 +394,3 @@ func (keyboard) Release(*wayland.Keyboard) {} | |||
| 396 | 394 | type touch struct{} | |
| 397 | 395 | ||
| 398 | 396 | func (touch) Release(*wayland.Touch) {} | |
| 399 | - | ||
| 400 | - | type dataManager struct{} | |
| 401 | - | ||
| 402 | - | func (dataManager) CreateDataSource(r *wayland.DataDeviceManager, id uint32) { | |
| 403 | - | wayland.NewDataSource(r.Client(), r.Version(), id, dataSource{}) | |
| 404 | - | } | |
| 405 | - | func (dataManager) GetDataDevice(r *wayland.DataDeviceManager, id uint32, _ *wayland.Seat) { | |
| 406 | - | wayland.NewDataDevice(r.Client(), r.Version(), id, dataDevice{}) | |
| 407 | - | } | |
| 408 | - | func (dataManager) Release(*wayland.DataDeviceManager) {} | |
| 409 | - | ||
| 410 | - | type dataSource struct{} | |
| 411 | - | ||
| 412 | - | func (dataSource) Offer(*wayland.DataSource, string) {} | |
| 413 | - | func (dataSource) Destroy(*wayland.DataSource) {} | |
| 414 | - | func (dataSource) SetActions(*wayland.DataSource, uint32) {} | |
| 415 | - | ||
| 416 | - | type dataDevice struct{} | |
| 417 | - | ||
| 418 | - | func (dataDevice) StartDrag(*wayland.DataDevice, *wayland.DataSource, *wayland.Surface, *wayland.Surface, uint32) { | |
| 419 | - | } | |
| 420 | - | func (dataDevice) SetSelection(*wayland.DataDevice, *wayland.DataSource, uint32) {} | |
| 421 | - | func (dataDevice) Release(*wayland.DataDevice) {} | |
| @@ -48,9 +48,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||||
| 48 | } | 48 | } | ||
| 49 | }) | 49 | }) | ||
| 50 | }, | 50 | }, | ||
| 51 | - | func() error { | 51 | + | func() error { return registerClipboard(d, s) }, |
| 52 | - | return wayland.NewDataDeviceManagerGlobal(d, 3, func(c server.Client, v, id uint32) { wayland.NewDataDeviceManager(c, int32(v), id, dataManager{}) }) | |||
| 53 | - | }, | |||
| 54 | } { | 52 | } { | ||
| 55 | if err := register(); err != nil { | 53 | if err := register(); err != nil { | ||
| 56 | return err | 54 | return err | ||
| @@ -396,26 +394,3 @@ func (keyboard) Release(*wayland.Keyboard) {} | |||||
| 396 | type touch struct{} | 394 | type touch struct{} | ||
| 397 | 395 | ||||
| 398 | func (touch) Release(*wayland.Touch) {} | 396 | func (touch) Release(*wayland.Touch) {} | ||
| 399 | - | ||||
| 400 | - | type dataManager struct{} | |||
| 401 | - | ||||
| 402 | - | func (dataManager) CreateDataSource(r *wayland.DataDeviceManager, id uint32) { | |||
| 403 | - | wayland.NewDataSource(r.Client(), r.Version(), id, dataSource{}) | |||
| 404 | - | } | |||
| 405 | - | func (dataManager) GetDataDevice(r *wayland.DataDeviceManager, id uint32, _ *wayland.Seat) { | |||
| 406 | - | wayland.NewDataDevice(r.Client(), r.Version(), id, dataDevice{}) | |||
| 407 | - | } | |||
| 408 | - | func (dataManager) Release(*wayland.DataDeviceManager) {} | |||
| 409 | - | ||||
| 410 | - | type dataSource struct{} | |||
| 411 | - | ||||
| 412 | - | func (dataSource) Offer(*wayland.DataSource, string) {} | |||
| 413 | - | func (dataSource) Destroy(*wayland.DataSource) {} | |||
| 414 | - | func (dataSource) SetActions(*wayland.DataSource, uint32) {} | |||
| 415 | - | ||||
| 416 | - | type dataDevice struct{} | |||
| 417 | - | ||||
| 418 | - | func (dataDevice) StartDrag(*wayland.DataDevice, *wayland.DataSource, *wayland.Surface, *wayland.Surface, uint32) { | |||
| 419 | - | } | |||
| 420 | - | func (dataDevice) SetSelection(*wayland.DataDevice, *wayland.DataSource, uint32) {} | |||
| 421 | - | func (dataDevice) Release(*wayland.DataDevice) {} | |||
M internal/adapters/wayland/server.go +6 -1
| @@ -88,6 +88,10 @@ type Server struct { | |||
| 88 | 88 | cursorLatest ports.CursorChange | |
| 89 | 89 | cursorQueued bool | |
| 90 | 90 | cursorReady chan struct{} | |
| 91 | + | // Clipboard and primary selection (clipboard.go). | |
| 92 | + | selections [2]*clipSource | |
| 93 | + | clipDevices []*clipDevice | |
| 94 | + | dataSources, primarySources, controlSources map[*server.Resource]*clipSource | |
| 91 | 95 | } | |
| 92 | 96 | ||
| 93 | 97 | func removeItem[T comparable](list []T, v T) []T { | |
| @@ -121,7 +125,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||
| 121 | 125 | d.Close() | |
| 122 | 126 | return nil, err | |
| 123 | 127 | } | |
| 124 | - | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 128 | + | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | |
| 125 | 129 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | |
| 126 | 130 | if opts.Keymap != "" { | |
| 127 | 131 | fd, size, e := keymapFile(opts.Keymap) | |
| @@ -550,6 +554,7 @@ func (s *Server) changeFocus(id ports.WindowID) { | |||
| 550 | 554 | s.focused = 0 | |
| 551 | 555 | if surf, keyboards := s.focusTarget(id); surf != nil { | |
| 552 | 556 | s.focused = id | |
| 557 | + | s.focusSelections() | |
| 553 | 558 | for _, k := range keyboards { | |
| 554 | 559 | s.serial++ | |
| 555 | 560 | var keys []byte | |
| @@ -88,6 +88,10 @@ type Server struct { | |||||
| 88 | cursorLatest ports.CursorChange | 88 | cursorLatest ports.CursorChange | ||
| 89 | cursorQueued bool | 89 | cursorQueued bool | ||
| 90 | cursorReady chan struct{} | 90 | cursorReady chan struct{} | ||
| 91 | + | // Clipboard and primary selection (clipboard.go). | |||
| 92 | + | selections [2]*clipSource | |||
| 93 | + | clipDevices []*clipDevice | |||
| 94 | + | dataSources, primarySources, controlSources map[*server.Resource]*clipSource | |||
| 91 | } | 95 | } | ||
| 92 | 96 | ||||
| 93 | func removeItem[T comparable](list []T, v T) []T { | 97 | func removeItem[T comparable](list []T, v T) []T { | ||
| @@ -121,7 +125,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) { | |||||
| 121 | d.Close() | 125 | d.Close() | ||
| 122 | return nil, err | 126 | return nil, err | ||
| 123 | } | 127 | } | ||
| 124 | - | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} | 128 | + | s := &Server{display: d, env: linuxProcEnv{}, name: name, cleanup: cleanup, log: log, channels: ch, surfaces: make(map[*server.Resource]*surface), buffers: make(map[*server.Resource]clientBuffer), windows: make(map[ports.WindowID]*window), layers: make(map[ports.WindowID]*layerSurface), nextWindow: 1, eventReady: make(chan struct{}, 1), contents: make(map[ports.WindowID]ports.SurfaceContent), contentSeq: make(map[ports.WindowID]uint64), contentReady: make(chan struct{}, 1), cursorReady: make(chan struct{}, 1), dataSources: map[*server.Resource]*clipSource{}, primarySources: map[*server.Resource]*clipSource{}, controlSources: map[*server.Resource]*clipSource{}, contentNotify: make(chan struct{}), keymapFD: -1, keyboards: make(map[server.Client][]*wayland.Keyboard), pointers: make(map[server.Client][]*wayland.Pointer), repeatRate: opts.RepeatRate, repeatDelay: opts.RepeatDelay} |
| 125 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | 129 | s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{} | ||
| 126 | if opts.Keymap != "" { | 130 | if opts.Keymap != "" { | ||
| 127 | fd, size, e := keymapFile(opts.Keymap) | 131 | fd, size, e := keymapFile(opts.Keymap) | ||
| @@ -550,6 +554,7 @@ func (s *Server) changeFocus(id ports.WindowID) { | |||||
| 550 | s.focused = 0 | 554 | s.focused = 0 | ||
| 551 | if surf, keyboards := s.focusTarget(id); surf != nil { | 555 | if surf, keyboards := s.focusTarget(id); surf != nil { | ||
| 552 | s.focused = id | 556 | s.focused = id | ||
| 557 | + | s.focusSelections() | |||
| 553 | for _, k := range keyboards { | 558 | for _, k := range keyboards { | ||
| 554 | s.serial++ | 559 | s.serial++ | ||
| 555 | var keys []byte | 560 | var keys []byte | ||
M internal/core/core.go +9 -0
| @@ -467,6 +467,15 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 467 | 467 | } | |
| 468 | 468 | ||
| 469 | 469 | func (c *Core) Run(ctx context.Context) error { | |
| 470 | + | // Startup commands run once per session; a config reload does not | |
| 471 | + | // run them again. | |
| 472 | + | for _, argv := range c.cfg.Startup { | |
| 473 | + | select { | |
| 474 | + | case <-ctx.Done(): | |
| 475 | + | return nil | |
| 476 | + | case c.ch.Spawn <- ports.SpawnRequest{Argv: slices.Clone(argv)}: | |
| 477 | + | } | |
| 478 | + | } | |
| 470 | 479 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | |
| 471 | 480 | return nil | |
| 472 | 481 | } | |
| @@ -467,6 +467,15 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||||
| 467 | } | 467 | } | ||
| 468 | 468 | ||||
| 469 | func (c *Core) Run(ctx context.Context) error { | 469 | func (c *Core) Run(ctx context.Context) error { | ||
| 470 | + | // Startup commands run once per session; a config reload does not | |||
| 471 | + | // run them again. | |||
| 472 | + | for _, argv := range c.cfg.Startup { | |||
| 473 | + | select { | |||
| 474 | + | case <-ctx.Done(): | |||
| 475 | + | return nil | |||
| 476 | + | case c.ch.Spawn <- ports.SpawnRequest{Argv: slices.Clone(argv)}: | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 470 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | 479 | if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil { | ||
| 471 | return nil | 480 | return nil | ||
| 472 | } | 481 | } | ||
M internal/core/slots_test.go +20 -0
| @@ -347,3 +347,23 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) { | |||
| 347 | 347 | t.Fatal(v) | |
| 348 | 348 | } | |
| 349 | 349 | } | |
| 350 | + | ||
| 351 | + | // Startup commands are spawned once, before the slots. | |
| 352 | + | func TestStartupCommands(t *testing.T) { | |
| 353 | + | cfg := config.Defaults() | |
| 354 | + | cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}} | |
| 355 | + | spawn := make(chan ports.SpawnRequest, 4) | |
| 356 | + | c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)}) | |
| 357 | + | if err != nil { | |
| 358 | + | t.Fatal(err) | |
| 359 | + | } | |
| 360 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 361 | + | done := make(chan error, 1) | |
| 362 | + | go func() { done <- c.Run(ctx) }() | |
| 363 | + | defer func() { cancel(); <-done }() | |
| 364 | + | for _, want := range cfg.Startup { | |
| 365 | + | if got := receive(t, spawn); !slices.Equal(got.Argv, want) { | |
| 366 | + | t.Fatalf("spawned %q, want %q", got.Argv, want) | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | } | |
| @@ -347,3 +347,23 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) { | |||||
| 347 | t.Fatal(v) | 347 | t.Fatal(v) | ||
| 348 | } | 348 | } | ||
| 349 | } | 349 | } | ||
| 350 | + | ||||
| 351 | + | // Startup commands are spawned once, before the slots. | |||
| 352 | + | func TestStartupCommands(t *testing.T) { | |||
| 353 | + | cfg := config.Defaults() | |||
| 354 | + | cfg.Startup = [][]string{{"wl-paste", "--watch", "cliphist", "store"}, {"waybar"}} | |||
| 355 | + | spawn := make(chan ports.SpawnRequest, 4) | |||
| 356 | + | c, err := core.New(cfg, core.Channels{Client: make(chan ports.ClientEvent), Input: make(chan ports.InputEvent), Output: make(chan ports.OutputEvent), Config: make(chan ports.ConfigChanged), Commands: make(chan ports.ClientCommand, 16), Spawn: spawn, Scenes: make(chan []ports.Scene, 1), ConfigErrors: make(chan error, 1)}) | |||
| 357 | + | if err != nil { | |||
| 358 | + | t.Fatal(err) | |||
| 359 | + | } | |||
| 360 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 361 | + | done := make(chan error, 1) | |||
| 362 | + | go func() { done <- c.Run(ctx) }() | |||
| 363 | + | defer func() { cancel(); <-done }() | |||
| 364 | + | for _, want := range cfg.Startup { | |||
| 365 | + | if got := receive(t, spawn); !slices.Equal(got.Argv, want) { | |||
| 366 | + | t.Fatalf("spawned %q, want %q", got.Argv, want) | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 369 | + | } | |||
M internal/ports/config.go +3 -1
| @@ -27,7 +27,9 @@ type Config struct { | |||
| 27 | 27 | RepeatDelay int | |
| 28 | 28 | CmdKey string | |
| 29 | 29 | } | |
| 30 | - | Terminal struct{ Command []string } | |
| 30 | + | Terminal struct{ Command []string } | |
| 31 | + | // Startup are commands run once when the session starts, in order. | |
| 32 | + | Startup [][]string | |
| 31 | 33 | Background struct{ Color string } | |
| 32 | 34 | // Border is drawn inside the window edge; Width 0 disables it. | |
| 33 | 35 | Border struct { | |
| @@ -27,7 +27,9 @@ type Config struct { | |||||
| 27 | RepeatDelay int | 27 | RepeatDelay int | ||
| 28 | CmdKey string | 28 | CmdKey string | ||
| 29 | } | 29 | } | ||
| 30 | - | Terminal struct{ Command []string } | 30 | + | Terminal struct{ Command []string } |
| 31 | + | // Startup are commands run once when the session starts, in order. | |||
| 32 | + | Startup [][]string | |||
| 31 | Background struct{ Color string } | 33 | Background struct{ Color string } | ||
| 32 | // Border is drawn inside the window edge; Width 0 disables it. | 34 | // Border is drawn inside the window edge; Width 0 disables it. | ||
| 33 | Border struct { | 35 | Border struct { | ||