v0.4.0 b6be3a8

clipboard_test.go RawHistoryFolder

1package wayland
2
3import (
4	"errors"
5	"testing"
6	"time"
7
8	"github.com/bnema/go-wayland-bindings/server/extdatacontrol"
9	"github.com/bnema/go-wayland-bindings/server/primaryselection"
10	"github.com/bnema/go-wayland-bindings/server/wayland"
11	"github.com/bnema/neferwl/internal/ports"
12	"github.com/bnema/wlturbo"
13	"golang.org/x/sys/unix"
14)
15
16// clipEvents records the selection offers sent to a data device.
17type clipEvents struct {
18	wlturbo.BaseProxy
19	c      *wlturbo.Display
20	offers chan uint32 // selection offer ids; 0 clears
21}
22
23func (p *clipEvents) Dispatch(e *wlturbo.Event) {
24	switch e.Opcode {
25	case uint16(wayland.DataDeviceEventDataOffer):
26		// The new offer: register it so its offer events are accepted.
27		registerOffer(p.c, e.Uint32())
28	case uint16(wayland.DataDeviceEventSelection):
29		p.offers <- e.Uint32()
30	}
31}
32
33type sourceEvents struct {
34	wlturbo.BaseProxy
35	sends     chan int
36	cancelled chan struct{}
37}
38
39func (p *sourceEvents) Dispatch(e *wlturbo.Event) {
40	switch e.Opcode {
41	case uint16(wayland.DataSourceEventSend):
42		_ = e.String()
43		fd := int(e.Fd())
44		_, _ = unix.Write(fd, []byte("pasted"))
45		_ = unix.Close(fd)
46		p.sends <- fd
47	case uint16(wayland.DataSourceEventCancelled):
48		p.cancelled <- struct{}{}
49	}
50}
51
52func registerOffer(c *wlturbo.Display, id uint32) {
53	p := &protocolProxy{}
54	p.SetID(id)
55	registerWireProxy(c, p)
56}
57
58// clipClient is a client with a toplevel, a data device and a pointer to
59// its clipboard globals.
60type clipClient struct {
61	c       *wlturbo.Display
62	manager uint32
63	device  *clipEvents
64	window  ports.WindowID
65}
66
67func newClipClient(t *testing.T, s *Server, dir string, events chan ports.ClientEvent) *clipClient {
68	t.Helper()
69	c := protocolClient(t, s, dir)
70	seat := bindProtocol(t, c, "wl_seat")
71	registerProtocol(t, c, seat)
72	g, _ := c.Registry().FindGlobal("wl_data_device_manager")
73	manager, err := bindWireID(c, g.Name, g.Interface, 3)
74	if err != nil {
75		t.Fatal(err)
76	}
77	device := &clipEvents{c: c, offers: make(chan uint32, 8)}
78	device.SetID(c.AllocateID())
79	registerWireProxy(c, device)
80	requestProtocol(t, c, manager, wayland.DataDeviceManagerRequestGetDataDevice, device.ID(), seat)
81	w := toplevelMapper(t, c, events)()
82	return &clipClient{c: c, manager: manager, device: device, window: w.ID}
83}
84
85// selection returns the next selection offer id sent to the client.
86func (cc *clipClient) selection(t *testing.T) (uint32, bool) {
87	t.Helper()
88	if err := cc.c.Roundtrip(); err != nil {
89		t.Fatal(err)
90	}
91	select {
92	case id := <-cc.device.offers:
93		return id, true
94	case <-time.After(100 * time.Millisecond):
95		return 0, false
96	}
97}
98
99func (cc *clipClient) copy(t *testing.T) *sourceEvents {
100	t.Helper()
101	src := &sourceEvents{sends: make(chan int, 4), cancelled: make(chan struct{}, 4)}
102	src.SetID(cc.c.AllocateID())
103	registerWireProxy(cc.c, src)
104	requestProtocol(t, cc.c, cc.manager, wayland.DataDeviceManagerRequestCreateDataSource, src.ID())
105	requestProtocol(t, cc.c, src.ID(), wayland.DataSourceRequestOffer, "text/plain")
106	requestProtocol(t, cc.c, cc.device.ID(), wayland.DataDeviceRequestSetSelection, src.ID(), uint32(0))
107	if err := cc.c.Roundtrip(); err != nil {
108		t.Fatal(err)
109	}
110	return src
111}
112
113// focus gives cc keyboard focus and waits for the selection it gets then.
114func (cc *clipClient) focus(t *testing.T, commands chan<- ports.ClientCommand) uint32 {
115	t.Helper()
116	commands <- ports.FocusWindow{ID: cc.window}
117	deadline := time.After(2 * time.Second)
118	for {
119		if id, ok := cc.selection(t); ok {
120			return id
121		}
122		select {
123		case <-deadline:
124			t.Fatal("no selection on focus")
125		default:
126		}
127	}
128}
129
130// The clipboard follows keyboard focus: only the focused client may set
131// it, and a client gets it when it gains focus; pasting reaches the source.
132func TestClipboardFollowsFocus(t *testing.T) {
133	s, events, commands, dir := keyboardServer(t)
134	a := newClipClient(t, s, dir, events)
135	b := newClipClient(t, s, dir, events)
136	a.focus(t, commands)
137
138	// An unfocused client cannot set the selection: its source is cancelled.
139	stolen := b.copy(t)
140	select {
141	case <-stolen.cancelled:
142	case <-time.After(2 * time.Second):
143		t.Fatal("unfocused source not cancelled")
144	}
145
146	src := a.copy(t)
147	offer, ok := a.selection(t)
148	if !ok || offer == 0 {
149		t.Fatalf("focused client got no offer: %d %v", offer, ok)
150	}
151	if id, ok := b.selection(t); ok {
152		t.Fatalf("unfocused client got selection %d", id)
153	}
154
155	// Focus moves to b: b gets the offer and can paste from a.
156	if offer = b.focus(t, commands); offer == 0 {
157		t.Fatal("no offer on focus")
158	}
159	var fds [2]int
160	if err := unix.Pipe2(fds[:], unix.O_CLOEXEC); err != nil {
161		t.Fatal(err)
162	}
163	defer unix.Close(fds[0])
164	if err := wireRequest(b.c, offer, uint16(wayland.DataOfferRequestReceive), []int{fds[1]}, "text/plain"); err != nil {
165		t.Fatal(err)
166	}
167	_ = unix.Close(fds[1])
168	if err := b.c.Roundtrip(); err != nil {
169		t.Fatal(err)
170	}
171	// a answers the send event.
172	deadline := time.After(2 * time.Second)
173	for len(src.sends) == 0 {
174		if err := a.c.Roundtrip(); err != nil {
175			t.Fatal(err)
176		}
177		select {
178		case <-deadline:
179			t.Fatal("source never asked to send")
180		case <-time.After(5 * time.Millisecond):
181		}
182	}
183	buf := make([]byte, 16)
184	// Go runtime signals (preemption) can interrupt the raw read: retry.
185	n, err := unix.Read(fds[0], buf)
186	for errors.Is(err, unix.EINTR) {
187		n, err = unix.Read(fds[0], buf)
188	}
189	if err != nil {
190		t.Fatal(err)
191	}
192	if string(buf[:n]) != "pasted" {
193		t.Fatalf("pasted %q", buf[:n])
194	}
195}
196
197// A clipboard manager (ext_data_control_v1) sees the selection without
198// focus, and a destroyed source clears it.
199func TestDataControlSeesSelection(t *testing.T) {
200	s, events, commands, dir := keyboardServer(t)
201	a := newClipClient(t, s, dir, events)
202	a.focus(t, commands)
203
204	m := protocolClient(t, s, dir)
205	seat := bindProtocol(t, m, "wl_seat")
206	registerProtocol(t, m, seat)
207	manager := bindProtocol(t, m, "ext_data_control_manager_v1")
208	offers := make(chan uint32, 8)
209	dev := &controlEvents{c: m, offers: offers}
210	dev.SetID(m.AllocateID())
211	registerWireProxy(m, dev)
212	requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)
213	next := func() uint32 {
214		t.Helper()
215		deadline := time.After(2 * time.Second)
216		for {
217			if err := m.Roundtrip(); err != nil {
218				t.Fatal(err)
219			}
220			select {
221			case id := <-offers:
222				return id
223			case <-deadline:
224				t.Fatal("no selection event")
225			case <-time.After(5 * time.Millisecond):
226			}
227		}
228	}
229	if id := next(); id != 0 {
230		t.Fatalf("initial selection %d, want none", id)
231	}
232	src := a.copy(t)
233	if id := next(); id == 0 {
234		t.Fatal("manager did not see the copy")
235	}
236	requestProtocol(t, a.c, src.ID(), wayland.DataSourceRequestDestroy)
237	if err := a.c.Roundtrip(); err != nil {
238		t.Fatal(err)
239	}
240	if id := next(); id != 0 {
241		t.Fatalf("selection %d after its source died", id)
242	}
243}
244
245type controlEvents struct {
246	wlturbo.BaseProxy
247	c      *wlturbo.Display
248	offers chan uint32
249}
250
251func (p *controlEvents) Dispatch(e *wlturbo.Event) {
252	switch e.Opcode {
253	case uint16(extdatacontrol.ExtDataControlDeviceV1EventDataOffer):
254		registerOffer(p.c, e.Uint32())
255	case uint16(extdatacontrol.ExtDataControlDeviceV1EventSelection):
256		p.offers <- e.Uint32()
257	}
258}
259
260// A new copy cancels the previous source; setting the current source
261// again changes nothing.
262func TestClipboardReplaceCancelsOldSource(t *testing.T) {
263	s, events, commands, dir := keyboardServer(t)
264	a := newClipClient(t, s, dir, events)
265	a.focus(t, commands)
266	first := a.copy(t)
267	if id, ok := a.selection(t); !ok || id == 0 {
268		t.Fatal("no offer for the first copy")
269	}
270	requestProtocol(t, a.c, a.device.ID(), wayland.DataDeviceRequestSetSelection, first.ID(), uint32(0))
271	if id, ok := a.selection(t); ok {
272		t.Fatalf("setting the same source again sent offer %d", id)
273	}
274	if len(first.cancelled) != 0 {
275		t.Fatal("current source cancelled when set again")
276	}
277	a.copy(t)
278	if err := a.c.Roundtrip(); err != nil {
279		t.Fatal(err)
280	}
281	select {
282	case <-first.cancelled:
283	case <-time.After(2 * time.Second):
284		t.Fatal("replaced source not cancelled")
285	}
286}
287
288// A data-control source can be set once.
289func TestDataControlUsedSource(t *testing.T) {
290	s, _, _, dir := keyboardServer(t)
291	m := protocolClient(t, s, dir)
292	seat := bindProtocol(t, m, "wl_seat")
293	registerProtocol(t, m, seat)
294	manager := bindProtocol(t, m, "ext_data_control_manager_v1")
295	dev := &controlEvents{c: m, offers: make(chan uint32, 8)}
296	dev.SetID(m.AllocateID())
297	registerWireProxy(m, dev)
298	requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), seat)
299	src := m.AllocateID()
300	registerProtocol(t, m, src)
301	requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestCreateDataSource, src)
302	requestProtocol(t, m, src, extdatacontrol.ExtDataControlSourceV1RequestOffer, "text/plain")
303	requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetSelection, src)
304	requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetPrimarySelection, src)
305	expectProtocolError(t, m, dev.ID(), uint32(extdatacontrol.ExtDataControlDeviceV1ErrorUsedSource))
306}
307
308type primaryEvents struct {
309	wlturbo.BaseProxy
310	c      *wlturbo.Display
311	offers chan uint32
312}
313
314func (p *primaryEvents) Dispatch(e *wlturbo.Event) {
315	switch e.Opcode {
316	case uint16(primaryselection.ZwpPrimarySelectionDeviceV1EventDataOffer):
317		registerOffer(p.c, e.Uint32())
318	case uint16(primaryselection.ZwpPrimarySelectionDeviceV1EventSelection):
319		p.offers <- e.Uint32()
320	}
321}
322
323// A clipboard manager setting the primary selection reaches the focused
324// client's primary selection device.
325func TestPrimarySelectionFromManager(t *testing.T) {
326	s, events, commands, dir := keyboardServer(t)
327	a := newClipClient(t, s, dir, events)
328	pm := bindProtocol(t, a.c, "zwp_primary_selection_device_manager_v1")
329	seat := bindProtocol(t, a.c, "wl_seat")
330	registerProtocol(t, a.c, seat)
331	pdev := &primaryEvents{c: a.c, offers: make(chan uint32, 8)}
332	pdev.SetID(a.c.AllocateID())
333	registerWireProxy(a.c, pdev)
334	requestProtocol(t, a.c, pm, primaryselection.ZwpPrimarySelectionDeviceManagerV1RequestGetDevice, pdev.ID(), seat)
335	a.focus(t, commands)
336
337	m := protocolClient(t, s, dir)
338	mseat := bindProtocol(t, m, "wl_seat")
339	registerProtocol(t, m, mseat)
340	manager := bindProtocol(t, m, "ext_data_control_manager_v1")
341	dev := &controlEvents{c: m, offers: make(chan uint32, 8)}
342	dev.SetID(m.AllocateID())
343	registerWireProxy(m, dev)
344	requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestGetDataDevice, dev.ID(), mseat)
345	src := m.AllocateID()
346	registerProtocol(t, m, src)
347	requestProtocol(t, m, manager, extdatacontrol.ExtDataControlManagerV1RequestCreateDataSource, src)
348	requestProtocol(t, m, src, extdatacontrol.ExtDataControlSourceV1RequestOffer, "text/plain")
349	requestProtocol(t, m, dev.ID(), extdatacontrol.ExtDataControlDeviceV1RequestSetPrimarySelection, src)
350	if err := m.Roundtrip(); err != nil {
351		t.Fatal(err)
352	}
353	deadline := time.After(2 * time.Second)
354	for {
355		if err := a.c.Roundtrip(); err != nil {
356			t.Fatal(err)
357		}
358		select {
359		case id := <-pdev.offers:
360			if id != 0 {
361				return
362			}
363		case <-deadline:
364			t.Fatal("focused client got no primary selection")
365		case <-time.After(5 * time.Millisecond):
366		}
367	}
368}

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