Skip to main content

wl_clipboard_rs/
watch.rs

1//! Watching the clipboard for selection changes with a [`Watcher`].
2
3use std::collections::{HashMap, VecDeque};
4use std::ffi::OsString;
5use std::io;
6use std::os::fd::AsFd;
7use std::sync::Arc;
8
9use os_pipe::{pipe, PipeReader, PipeWriter};
10use rustix::event::{PollFd, PollFlags};
11use wayland_backend::client::WaylandError;
12use wayland_client::globals::GlobalListContents;
13use wayland_client::protocol::wl_registry::WlRegistry;
14use wayland_client::protocol::wl_seat::WlSeat;
15use wayland_client::{delegate_dispatch, event_created_child, Dispatch, EventQueue};
16
17use crate::common::{self, initialize};
18use crate::data_control::{self, impl_dispatch_device, impl_dispatch_manager, impl_dispatch_offer};
19use crate::paste::{self, Error, Seat};
20
21struct State {
22    common: common::State,
23    // Maps each newly introduced offer to its advertised MIME types, populated as Offer events
24    // arrive. The Selection event that follows moves the entry into its `SelectionEvent`.
25    offers: HashMap<data_control::Offer, Vec<String>>,
26    selection_events: VecDeque<SelectionEvent>,
27}
28
29impl State {
30    fn got_primary_selection(&self) -> bool {
31        self.selection_events
32            .iter()
33            .any(|event| event.clipboard == paste::ClipboardType::Primary)
34    }
35
36    fn push_selection_event(
37        &mut self,
38        clipboard: paste::ClipboardType,
39        seat: &WlSeat,
40        offer: Option<data_control::Offer>,
41    ) {
42        let offer = offer.map(|offer| {
43            // Each offer belongs to exactly one selection event, so take its MIME types here.
44            let mime_types = self.offers.remove(&offer).unwrap_or_default();
45            SelectionOffer {
46                offer: OwnedOffer(offer),
47                mime_types,
48            }
49        });
50        self.selection_events.push_back(SelectionEvent {
51            clipboard,
52            seat: seat.clone(),
53            offer,
54        });
55    }
56}
57
58/// Pending selection event to report from the watch loop.
59struct SelectionEvent {
60    clipboard: paste::ClipboardType,
61    seat: WlSeat,
62    offer: Option<SelectionOffer>,
63}
64
65struct SelectionOffer {
66    offer: OwnedOffer,
67    mime_types: Vec<String>,
68}
69
70// Not `Clone`: dropping it destroys the underlying Wayland object.
71struct OwnedOffer(data_control::Offer);
72
73impl Drop for OwnedOffer {
74    fn drop(&mut self) {
75        self.0.destroy();
76    }
77}
78
79delegate_dispatch!(State: [WlSeat: ()] => common::State);
80
81impl AsMut<common::State> for State {
82    fn as_mut(&mut self) -> &mut common::State {
83        &mut self.common
84    }
85}
86
87impl Dispatch<WlRegistry, GlobalListContents> for State {
88    fn event(
89        _state: &mut Self,
90        _proxy: &WlRegistry,
91        _event: <WlRegistry as wayland_client::Proxy>::Event,
92        _data: &GlobalListContents,
93        _conn: &wayland_client::Connection,
94        _qhandle: &wayland_client::QueueHandle<Self>,
95    ) {
96    }
97}
98
99impl_dispatch_manager!(State);
100
101impl_dispatch_device!(State, WlSeat, |state: &mut Self, event, seat: &WlSeat| {
102    match event {
103        Event::DataOffer { id } => {
104            let offer = data_control::Offer::from(id);
105            state.offers.insert(offer, Vec::new());
106        }
107        Event::Selection { id } => {
108            state.push_selection_event(
109                paste::ClipboardType::Regular,
110                seat,
111                id.map(data_control::Offer::from),
112            );
113        }
114        Event::Finished => {
115            // Destroy the device stored in the seat as it's no longer valid.
116            let seat_data = state.common.seats.get_mut(seat).unwrap();
117            seat_data.set_device(None);
118        }
119        Event::PrimarySelection { id } => {
120            state.push_selection_event(
121                paste::ClipboardType::Primary,
122                seat,
123                id.map(data_control::Offer::from),
124            );
125        }
126        _ => (),
127    }
128});
129
130impl_dispatch_offer!(State, |state: &mut Self,
131                             offer: data_control::Offer,
132                             event| {
133    if let Event::Offer { mime_type } = event {
134        state.offers.get_mut(&offer).unwrap().push(mime_type);
135    }
136});
137
138/// Handle used to stop a running [`Watcher`] from another thread.
139///
140/// Obtain one with [`Watcher::cancel_handle`]. Clone-able and safe to send across threads.
141#[derive(Clone)]
142pub struct CancelHandle(Arc<PipeWriter>);
143
144impl CancelHandle {
145    /// Signals the associated [`Watcher`] to stop.
146    ///
147    /// Returns immediately; the watcher exits before its next blocking wait.
148    pub fn cancel(&self) {
149        let _ = rustix::io::write(&*self.0, &[0u8]);
150    }
151}
152
153/// The clipboard to watch.
154#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, PartialOrd, Ord, Default)]
155pub enum ClipboardType {
156    /// The regular clipboard.
157    #[default]
158    Regular,
159    /// The "primary" clipboard.
160    ///
161    /// Watching the "primary" clipboard requires the compositor to support ext-data-control,
162    /// or wlr-data-control version 2 or above.
163    Primary,
164    /// Watch both clipboards at once.
165    ///
166    /// Each [`ClipboardEvent`] reports which clipboard it came from. This option requires the
167    /// "primary" clipboard to be supported.
168    Both,
169}
170
171impl ClipboardType {
172    fn includes(self, clipboard: paste::ClipboardType) -> bool {
173        match self {
174            ClipboardType::Regular => clipboard == paste::ClipboardType::Regular,
175            ClipboardType::Primary => clipboard == paste::ClipboardType::Primary,
176            ClipboardType::Both => true,
177        }
178    }
179}
180
181impl From<paste::ClipboardType> for ClipboardType {
182    fn from(clipboard: paste::ClipboardType) -> Self {
183        match clipboard {
184            paste::ClipboardType::Regular => ClipboardType::Regular,
185            paste::ClipboardType::Primary => ClipboardType::Primary,
186        }
187    }
188}
189
190/// A clipboard selection event reported by [`Watcher::next_event`].
191pub enum ClipboardEvent<'a> {
192    /// The selection has changed.
193    Changed {
194        /// The clipboard whose selection changed.
195        clipboard: paste::ClipboardType,
196        /// The offered MIME types in protocol order.
197        mime_types: Vec<String>,
198        /// The offer used to receive the data.
199        offer: Offer<'a>,
200    },
201    /// The selection was cleared.
202    Cleared {
203        /// The clipboard that was cleared.
204        clipboard: paste::ClipboardType,
205    },
206}
207
208/// Watches the clipboard for selection changes.
209///
210/// Construct one with [`Watcher::new`], then drive it by calling [`Watcher::next_event`] in a
211/// loop. The first call reports the current selection state; subsequent calls block until the
212/// selection changes again.
213///
214/// Seats are resolved when the watcher starts, so passing [`Seat::Unspecified`] will use the
215/// first seat found on construction rather than re-enumerating seats as offers come in.
216pub struct Watcher {
217    queue: EventQueue<State>,
218    state: State,
219    clipboard: ClipboardType,
220    // The single seat whose selections we report, resolved at construction.
221    watched: WlSeat,
222    // Cancellation pipe: the read end is polled in `wait`; the write end is handed out as a
223    // `CancelHandle` and becomes readable once `CancelHandle::cancel` is called.
224    cancel_read: PipeReader,
225    cancel_write: Arc<PipeWriter>,
226}
227
228impl Watcher {
229    /// Starts watching the clipboard.
230    ///
231    /// Returns an error immediately if there are no seats, the requested seat or protocol is
232    /// missing, or primary selection was requested but is unsupported.
233    pub fn new(clipboard: ClipboardType, seat: Seat<'_>) -> Result<Self, Error> {
234        Self::with_socket(clipboard, seat, None)
235    }
236
237    // The internal constructor accepts the socket name, used for tests.
238    pub(crate) fn with_socket(
239        clipboard: ClipboardType,
240        seat: Seat<'_>,
241        socket_name: Option<OsString>,
242    ) -> Result<Self, Error> {
243        let (mut queue, mut common) = initialize(clipboard != ClipboardType::Regular, socket_name)?;
244
245        if common.seats.is_empty() {
246            return Err(Error::NoSeats);
247        }
248
249        for (seat, data) in &mut common.seats {
250            let device =
251                common
252                    .clipboard_manager
253                    .get_data_device(seat, &queue.handle(), seat.clone());
254            data.set_device(Some(device));
255        }
256
257        let mut state = State {
258            common,
259            offers: HashMap::new(),
260            selection_events: VecDeque::new(),
261        };
262
263        queue
264            .roundtrip(&mut state)
265            .map_err(Error::WaylandCommunication)?;
266
267        if clipboard != ClipboardType::Regular && !state.got_primary_selection() {
268            return Err(Error::PrimarySelectionUnsupported);
269        }
270
271        let seats = &state.common.seats;
272        let watched = match seat {
273            Seat::Unspecified => seats.keys().next().cloned(),
274            Seat::Specific(name) => seats
275                .iter()
276                .find(|(_, data)| data.name.as_deref() == Some(name))
277                .map(|(seat, _)| seat.clone()),
278        };
279        let Some(watched) = watched else {
280            return Err(Error::SeatNotFound);
281        };
282
283        let (cancel_read, cancel_write) = pipe().map_err(Error::PipeCreation)?;
284
285        Ok(Watcher {
286            queue,
287            state,
288            clipboard,
289            watched,
290            cancel_read,
291            cancel_write: Arc::new(cancel_write),
292        })
293    }
294
295    /// Returns a handle that stops this watcher when [`CancelHandle::cancel`] is called.
296    ///
297    /// The handle can be cloned and sent to another thread to interrupt a [`Watcher::next_event`]
298    /// call that is blocked waiting for the next selection change.
299    pub fn cancel_handle(&self) -> CancelHandle {
300        CancelHandle(Arc::clone(&self.cancel_write))
301    }
302
303    /// Blocks until the next selection event for the watched clipboard and seat.
304    ///
305    /// On success yields the [`ClipboardEvent`] and an [`Offer`] to read its contents from.
306    /// Returns `Ok(None)` if cancelled via [`CancelHandle::cancel`], or `Err` on a Wayland
307    /// communication failure.
308    pub fn next_event<'a>(&'a mut self) -> Result<Option<ClipboardEvent<'a>>, Error> {
309        while !self.front_matches() {
310            if self.wait()? {
311                return Ok(None);
312            }
313        }
314        Ok(Some(self.take_front_event()))
315    }
316
317    // Discards leading events for other clipboards/seats, returning whether a matching event is now
318    // at the front of the queue.
319    fn front_matches(&mut self) -> bool {
320        while let Some(event) = self.state.selection_events.front() {
321            if self.clipboard.includes(event.clipboard) && event.seat == self.watched {
322                return true;
323            }
324            self.state.selection_events.pop_front();
325        }
326        false
327    }
328
329    // Removes the front event, returning its kind and an [`Offer`] to receive from. Only call when
330    // `front_matches` returned `true`.
331    fn take_front_event<'a>(&'a mut self) -> ClipboardEvent<'a> {
332        let SelectionEvent {
333            clipboard, offer, ..
334        } = self.state.selection_events.pop_front().unwrap();
335        match offer {
336            Some(SelectionOffer { offer, mime_types }) => ClipboardEvent::Changed {
337                clipboard,
338                mime_types,
339                offer: Offer {
340                    watcher: self,
341                    offer,
342                },
343            },
344            None => ClipboardEvent::Cleared { clipboard },
345        }
346    }
347
348    // Blocks until more Wayland events arrive (or cancellation). Returns `Ok(true)` if cancelled.
349    fn wait(&mut self) -> Result<bool, Error> {
350        self.queue
351            .flush()
352            .map_err(|e| Error::WaylandCommunication(e.into()))?;
353
354        if let Some(guard) = self.queue.prepare_read() {
355            let wayland_fd = guard.connection_fd();
356            let mut poll_fds = [
357                PollFd::new(&wayland_fd, PollFlags::IN | PollFlags::ERR),
358                PollFd::new(&self.cancel_read, PollFlags::IN),
359            ];
360            loop {
361                match rustix::event::poll(&mut poll_fds, None) {
362                    Ok(_) => break,
363                    Err(rustix::io::Errno::INTR) => continue,
364                    Err(e) => {
365                        return Err(Error::WaylandCommunication(
366                            WaylandError::Io(e.into()).into(),
367                        ))
368                    }
369                }
370            }
371            // Got data on the cancel pipe, bail with `true`.
372            if poll_fds[1].revents().contains(PollFlags::IN) {
373                return Ok(true);
374            }
375            match guard.read() {
376                Ok(_) => {}
377                Err(WaylandError::Io(e)) if e.kind() == io::ErrorKind::WouldBlock => {}
378                Err(e) => return Err(Error::WaylandCommunication(e.into())),
379            }
380        }
381
382        self.queue
383            .dispatch_pending(&mut self.state)
384            .map_err(Error::WaylandCommunication)?;
385        Ok(false)
386    }
387}
388
389/// The data offer accompanying a [`ClipboardEvent`], borrowed from its [`Watcher`].
390pub struct Offer<'a> {
391    watcher: &'a mut Watcher,
392    offer: OwnedOffer,
393}
394
395impl Offer<'_> {
396    /// Reads the clipboard content of the given MIME type into a pipe.
397    ///
398    /// Returns `Err(Error::ClipboardEmpty)` on a [`ClipboardEvent::Cleared`] event.
399    pub fn receive(&mut self, mime_type: &str) -> Result<PipeReader, Error> {
400        let (read, write) = pipe().map_err(Error::PipeCreation)?;
401        self.offer.0.receive(mime_type.to_string(), write.as_fd());
402        drop(write);
403        self.watcher
404            .queue
405            .flush()
406            .map_err(|e| Error::WaylandCommunication(e.into()))?;
407        Ok(read)
408    }
409}