v0.4.0 2de4e1d

clipboard.go RawHistoryFolder

1//go:build linux
2
3package wayland
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"os"
10	"sync"
11	"syscall"
12	"time"
13
14	"github.com/bnema/nefergui/internal/edit"
15	"github.com/bnema/wlturbo"
16	"github.com/bnema/wlturbo/protocol/core"
17	"github.com/bnema/wlturbo/wl"
18	"golang.org/x/sys/unix"
19)
20
21const (
22	mimeUTF8        = "text/plain;charset=utf-8"
23	mimeX11         = "UTF8_STRING"
24	mimePlain       = "text/plain"
25	transferTimeout = 3 * time.Second
26)
27
28// ClipboardResult is delivered through the session event queue, never from
29// the reader into the editor. Data may contain invalid UTF-8; edit validates it.
30type ClipboardResult struct {
31	Data []byte
32	Err  error
33}
34type ClipboardEvent struct {
35	kind   string
36	offer  *core.DataOffer
37	mime   string
38	source *core.DataSource
39}
40
41// Clipboard is the asynchronous Wayland clipboard adapter. The synchronous
42// edit.Clipboard.ReadText contract cannot be used on the UI loop: callers must
43// use ReadTextAsync and dispatch ClipboardResult on their next frame.
44type Clipboard struct {
45	manager *core.DataDeviceManager
46	version uint32 // negotiated wl_data_device_manager version
47	device  *core.DataDevice
48	offer   *core.DataOffer
49	dnd     *core.DataOffer
50	pending map[*core.DataOffer]map[string]bool
51	offers  map[uint32]*core.DataOffer
52	mimes   map[string]bool
53	source  *core.DataSource
54	serial  func() uint32
55	post    func(ClipboardEvent)
56	closed  bool
57	stop    context.CancelFunc
58	workers sync.WaitGroup
59	mu      sync.Mutex
60}
61
62var _ edit.AsyncClipboard = (*Clipboard)(nil)
63
64// NewClipboard binds the optional manager at its announced version and creates
65// one data device for seat. Call from the session loop after seat binding.
66func NewClipboard(d *wl.Display, seat *core.Seat, serial func() uint32, post func(ClipboardEvent)) (*Clipboard, error) {
67	if seat == nil || d == nil {
68		return nil, nil
69	}
70	manager := core.NewDataDeviceManager(d.Context())
71	version, err := d.Registry().BindNegotiated(core.DataDeviceManagerInterface, 3, manager)
72	if err != nil {
73		d.Context().Unregister(manager)
74		if errors.Is(err, wlturbo.ErrGlobalNotFound) {
75			return nil, nil
76		}
77		return nil, err
78	}
79	device, err := manager.GetDataDevice(seat)
80	if err != nil {
81		d.Context().Unregister(manager)
82		return nil, err
83	}
84	c := &Clipboard{manager: manager, version: version, device: device, serial: serial, post: post, pending: make(map[*core.DataOffer]map[string]bool), offers: make(map[uint32]*core.DataOffer)}
85	device.OnDataOffer(func(o *core.DataOffer) {
86		c.mu.Lock()
87		if c.closed {
88			c.mu.Unlock()
89			_ = o.Destroy()
90			return
91		}
92		c.offers[o.ID()] = o
93		c.mu.Unlock()
94		c.post(ClipboardEvent{kind: "offer", offer: o})
95		o.OnOffer(func(m string) { c.post(ClipboardEvent{kind: "mime", offer: o, mime: m}) })
96	})
97	device.OnSelection(func(id uint32) { c.post(ClipboardEvent{kind: "selection", offer: c.findOffer(id)}) })
98	// Drag-and-drop is unsupported in V1; retain its offer until leave/drop.
99	device.OnEnter(func(_ uint32, _ uint32, _, _ wl.Fixed, id uint32) {
100		c.post(ClipboardEvent{kind: "enter", offer: c.findOffer(id)})
101	})
102	device.OnLeave(func() { c.post(ClipboardEvent{kind: "leave"}) })
103	device.OnDrop(func() { c.post(ClipboardEvent{kind: "drop"}) })
104	return c, nil
105}
106
107// findOffer only resolves IDs from reader-owned notifications. It does not
108// mutate clipboard state; the loop decides which offer is current.
109func (c *Clipboard) findOffer(id uint32) *core.DataOffer {
110	if id == 0 {
111		return nil
112	}
113	c.mu.Lock()
114	defer c.mu.Unlock()
115	return c.offers[id]
116}
117
118// ApplyClipboard handles reader events on the session loop.
119func (c *Clipboard) ApplyClipboard(ev ClipboardEvent) {
120	switch ev.kind {
121	case "offer":
122		c.pending[ev.offer] = make(map[string]bool)
123	case "mime":
124		if m, ok := c.pending[ev.offer]; ok {
125			m[ev.mime] = true
126		} else if ev.offer == c.offer {
127			c.mimes[ev.mime] = true
128		}
129	case "selection":
130		if c.offer != nil && c.offer != ev.offer {
131			c.destroyOffer(c.offer)
132		}
133		c.offer = ev.offer
134		c.mimes = c.pending[ev.offer]
135		if c.mimes == nil {
136			c.mimes = make(map[string]bool)
137		}
138		delete(c.pending, ev.offer)
139		for o := range c.pending {
140			if o == c.dnd {
141				continue
142			}
143			if o != ev.offer {
144				c.destroyOffer(o)
145				delete(c.pending, o)
146			}
147		}
148	case "enter":
149		c.clearDnD()
150		c.dnd = ev.offer
151		delete(c.pending, ev.offer)
152	case "leave", "drop":
153		c.clearDnD()
154	case "cancelled":
155		if c.source == ev.source {
156			_ = c.source.Destroy()
157			c.source = nil
158		}
159	}
160}
161func (c *Clipboard) clearDnD() {
162	if c.dnd != nil && c.dnd != c.offer {
163		c.destroyOffer(c.dnd)
164	}
165	c.dnd = nil
166}
167func (c *Clipboard) destroyOffer(o *core.DataOffer) {
168	c.mu.Lock()
169	delete(c.offers, o.ID())
170	c.mu.Unlock()
171	_ = o.Destroy()
172}
173func (c *Clipboard) bestMIME() string {
174	for _, m := range []string{mimeUTF8, mimeX11, mimePlain} {
175		if c.mimes[m] {
176			return m
177		}
178	}
179	return ""
180}
181
182// ReadTextAsync must be called on the loop. The compositor receives the write
183// end; a worker reads at most limit+1 bytes. The caller posts the callback to
184// its UI event queue. Concurrent reads cancel the previous transfer. Close
185// cancels and joins reads, not callbacks: a callback racing Close may still run
186// after Close returns. Callers must discard results when shutting down.
187func (c *Clipboard) ReadTextAsync(ctx context.Context, limit int, deliver func([]byte, error)) error {
188	if c == nil || c.offer == nil {
189		return errors.New("clipboard unavailable")
190	}
191	mime := c.bestMIME()
192	if mime == "" {
193		return errors.New("no text MIME offered")
194	}
195	if limit < 0 || limit > edit.MaxClipboardBytes {
196		limit = edit.MaxClipboardBytes
197	}
198	fd := make([]int, 2)
199	if err := unix.Pipe2(fd, unix.O_CLOEXEC|unix.O_NONBLOCK); err != nil {
200		return err
201	}
202	r := os.NewFile(uintptr(fd[0]), "clipboard-read")
203	if err := c.offer.Receive(mime, fd[1]); err != nil {
204		_ = syscall.Close(fd[1])
205		_ = r.Close()
206		return err
207	}
208	// WLTurbo closes the sent FD after a successful send; do not close it twice.
209	ctx, cancel := context.WithTimeout(ctx, transferTimeout)
210	c.mu.Lock()
211	if c.closed {
212		c.mu.Unlock()
213		cancel()
214		_ = r.Close()
215		return errors.New("clipboard closed")
216	}
217	if c.stop != nil {
218		c.stop()
219	}
220	c.stop = cancel
221	c.workers.Add(1)
222	c.mu.Unlock()
223	go func() {
224		defer c.workers.Done()
225		defer cancel()
226		defer r.Close()
227		// readClipboard polls ctx every 50ms; closing r from another goroutine
228		// while it is read would race on the descriptor.
229		data, e := readClipboard(ctx, r, limit)
230		if ctx.Err() != nil {
231			e = ctx.Err()
232		}
233		c.mu.Lock()
234		open := !c.closed
235		c.mu.Unlock()
236		if !open || ctx.Err() != nil {
237			return
238		}
239		// Arbitrary callbacks cannot be forcibly interrupted. They are not
240		// joined by Close: a blocked UI enqueue must not stall shutdown.
241		go deliver(data, e)
242	}()
243	return nil
244}
245
246// readClipboard polls a nonblocking pipe so cancellation and timeouts are
247// effective even if the compositor never closes its write end.
248func readClipboard(ctx context.Context, r *os.File, limit int) ([]byte, error) {
249	data := make([]byte, 0, min(limit, 4096))
250	buf := make([]byte, 4096)
251	for {
252		if err := ctx.Err(); err != nil {
253			return nil, err
254		}
255		n, e := unix.Read(int(r.Fd()), buf[:min(len(buf), limit+1-len(data))])
256		if n > 0 {
257			data = append(data, buf[:n]...)
258			if len(data) > limit {
259				return nil, fmt.Errorf("clipboard exceeds %d bytes", limit)
260			}
261		}
262		if e == nil && n == 0 {
263			return data, nil
264		}
265		if e == nil {
266			continue
267		}
268		if e != unix.EAGAIN && e != unix.EINTR {
269			return nil, e
270		}
271		poll := []unix.PollFd{{Fd: int32(r.Fd()), Events: unix.POLLIN | unix.POLLHUP}}
272		_, e = unix.Poll(poll, 50)
273		if e != nil && e != unix.EINTR {
274			return nil, e
275		}
276	}
277}
278
279// ReadText deliberately refuses to block the UI loop. Use ReadTextAsync.
280func (c *Clipboard) ReadText(int) ([]byte, error) {
281	return nil, errors.New("wayland clipboard requires ReadTextAsync")
282}
283
284func (c *Clipboard) WriteText(data []byte) error {
285	if c == nil || c.device == nil {
286		return errors.New("clipboard unavailable")
287	}
288	c.mu.Lock()
289	closed := c.closed
290	c.mu.Unlock()
291	if closed {
292		return errors.New("clipboard closed")
293	}
294	if len(data) > edit.MaxClipboardBytes {
295		return errors.New("clipboard too large")
296	}
297	serial := c.serial()
298	if serial == 0 {
299		return errors.New("no keyboard selection serial")
300	}
301	source, err := c.manager.CreateDataSource()
302	if err != nil {
303		return err
304	}
305	for _, m := range []string{mimeUTF8, mimeX11, mimePlain} {
306		if err = source.Offer(m); err != nil {
307			_ = source.Destroy()
308			return err
309		}
310	}
311	payload := append([]byte(nil), data...)
312	source.OnSend(func(m string, fd *wl.OwnedFD) {
313		n, e := fd.Take()
314		if e != nil {
315			return
316		}
317		if m != mimeUTF8 && m != mimeX11 && m != mimePlain {
318			_ = os.NewFile(uintptr(n), "selection").Close()
319			return
320		}
321		go writeSelection(n, payload)
322	})
323	source.OnCancelled(func() { c.post(ClipboardEvent{kind: "cancelled", source: source}) })
324	if err = c.device.SetSelection(source, serial); err != nil {
325		_ = source.Destroy()
326		return err
327	}
328	if c.source != nil {
329		_ = c.source.Destroy()
330	}
331	c.source = source
332	return nil
333}
334func writeSelection(fd int, data []byte) {
335	f := os.NewFile(uintptr(fd), "selection")
336	defer f.Close()
337	_ = unix.SetNonblock(fd, true)
338	deadline := time.Now().Add(transferTimeout)
339	for len(data) > 0 && time.Now().Before(deadline) {
340		n, err := unix.Write(fd, data)
341		if n > 0 {
342			data = data[n:]
343		}
344		if err != nil && err != unix.EAGAIN && err != unix.EINTR {
345			return
346		}
347		if n == 0 || err == unix.EAGAIN {
348			_, _ = unix.Poll([]unix.PollFd{{Fd: int32(fd), Events: unix.POLLOUT}}, 50)
349		}
350	}
351}
352func (c *Clipboard) Close() {
353	if c == nil {
354		return
355	}
356	c.mu.Lock()
357	c.closed = true
358	if c.stop != nil {
359		c.stop()
360	}
361	c.mu.Unlock()
362	c.workers.Wait() // reads poll cancellation every 50ms (transfer has a 3s deadline)
363	c.clearDnD()
364	if c.offer != nil {
365		c.destroyOffer(c.offer)
366	}
367	for o := range c.pending {
368		if o != c.offer {
369			c.destroyOffer(o)
370		}
371	}
372	if c.source != nil {
373		_ = c.source.Destroy()
374	}
375	if c.device != nil {
376		if c.version >= 2 {
377			_ = c.device.Release()
378		} else {
379			if ctx := c.device.Context(); ctx != nil {
380				ctx.Unregister(c.device)
381			}
382		}
383	}
384	// wl_data_device_manager has no destructor below version 4: sending
385	// release is a protocol error, so only forget the proxy locally.
386	if c.manager != nil {
387		if ctx := c.manager.Context(); ctx != nil {
388			ctx.Unregister(c.manager)
389		}
390	}
391}

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 1

Tags 12

↑↓ moveenter openesc close