detached 0ba02a4

feat(clipboard): clipboard, primary selection, data-control and startup commands

commit
0ba02a4fc0c139386895e7defba97de66ae4f099
author
brice · · 2026-09-25 10:26 UTC
parent
4ca9e48
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +911 -28
M README.md +19 -0
@@ -25,12 +25,31 @@ keyboard.layout = fr
2525 terminal = foot
2626 bind.cmd+return = spawn-terminal
2727 bind.ctrl+cmd+space = spawn fuzzel
28+startup = wl-paste --watch cliphist store
2829 ```
2930
3031 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.
3132
3233 Building requires sibling checkouts of purego-libwayland and purego-vulkan until they are published.
3334
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+
3453 ## State for scripts
3554
3655 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 = foot25 terminal = foot
26 bind.cmd+return = spawn-terminal26 bind.cmd+return = spawn-terminal
27 bind.ctrl+cmd+space = spawn fuzzel27 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 scripts53 ## 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
1414 # Command for spawn-terminal and the terminal opened at startup.
1515 terminal = foot
1616
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+
1723 background = #111111
1824
1925 # 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 = foot15 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 = #11111123 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) {
262262 warn("%s: %v", key, err)
263263 continue
264264 }
265+ if key == "startup" {
266+ // Repeatable: each line adds a command.
267+ raw[fmt.Sprintf("startup.%d", len(c.Startup))] = value
268+ continue
269+ }
265270 override()
266271 }
267272 if err := scanner.Err(); err != nil {
@@ -371,6 +376,12 @@ func set(c *ports.Config, key, v string) error {
371376 return fmt.Errorf("must be super, alt or ctrl")
372377 }
373378 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)
374385 case "terminal":
375386 argv := strings.Fields(v)
376387 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 continue263 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 = v378 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
6262 output.DP-1.primary = on
6363 bind.cmd+code:30 = quit
6464 render.direct-scanout = off
65+startup = wl-paste --watch cliphist store
66+startup = wl-paste --primary --watch cliphist store
6567 `)
6668 if len(w) != 0 {
6769 t.Fatal(w)
6870 }
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+ }
6974 if c.Keyboard.Layout != "fr" || c.Keyboard.RepeatRate != 40 || !reflect.DeepEqual(c.Terminal.Command, []string{"foot", "--server"}) || c.Background.Color != "#000000" || c.Render.DirectScanout {
7075 t.Fatalf("%+v", c)
7176 }
@@ -62,10 +62,15 @@ output.DP-1.scale = 1.5
62 output.DP-1.primary = on62 output.DP-1.primary = on
63 bind.cmd+code:30 = quit63 bind.cmd+code:30 = quit
64 render.direct-scanout = off64 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 {
4848 }
4949 })
5050 },
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) },
5452 } {
5553 if err := register(); err != nil {
5654 return err
@@ -396,26 +394,3 @@ func (keyboard) Release(*wayland.Keyboard) {}
396394 type touch struct{}
397395
398396 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 err54 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 {
8888 cursorLatest ports.CursorChange
8989 cursorQueued bool
9090 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
9195 }
9296
9397 func removeItem[T comparable](list []T, v T) []T {
@@ -121,7 +125,7 @@ func New(opts Options, ch Channels, log zerowrap.Logger) (*Server, error) {
121125 d.Close()
122126 return nil, err
123127 }
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}
125129 s.fractions = map[*surface]*fractionalscale.WpFractionalScaleV1{}
126130 if opts.Keymap != "" {
127131 fd, size, e := keymapFile(opts.Keymap)
@@ -550,6 +554,7 @@ func (s *Server) changeFocus(id ports.WindowID) {
550554 s.focused = 0
551555 if surf, keyboards := s.focusTarget(id); surf != nil {
552556 s.focused = id
557+ s.focusSelections()
553558 for _, k := range keyboards {
554559 s.serial++
555560 var keys []byte
@@ -88,6 +88,10 @@ type Server struct {
88 cursorLatest ports.CursorChange88 cursorLatest ports.CursorChange
89 cursorQueued bool89 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, err126 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 = 0554 s.focused = 0
551 if surf, keyboards := s.focusTarget(id); surf != nil {555 if surf, keyboards := s.focusTarget(id); surf != nil {
552 s.focused = id556 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 []byte560 var keys []byte
M internal/core/core.go +9 -0
@@ -467,6 +467,15 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) {
467467 }
468468
469469 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+ }
470479 if c.spawnSlots(ctx, false) != nil || c.publishPending(ctx) != nil {
471480 return nil
472481 }
@@ -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 nil480 return nil
472 }481 }
M internal/core/slots_test.go +20 -0
@@ -347,3 +347,23 @@ func TestSlotReleasedWhenWindowMoved(t *testing.T) {
347347 t.Fatal(v)
348348 }
349349 }
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 {
2727 RepeatDelay int
2828 CmdKey string
2929 }
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
3133 Background struct{ Color string }
3234 // Border is drawn inside the window edge; Width 0 disables it.
3335 Border struct {
@@ -27,7 +27,9 @@ type Config struct {
27 RepeatDelay int27 RepeatDelay int
28 CmdKey string28 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 {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close