Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 43 additions & 53 deletions crates/neomacs-display-runtime/src/clipboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use arboard::{ClearExtLinux, GetExtLinux, LinuxClipboardKind, SetExtLinux};
#[cfg(target_os = "linux")]
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};

mod text_policy;

#[cfg(target_os = "linux")]
mod wayland_data_control;

Expand All @@ -21,7 +23,7 @@ trait ClipboardBackend: Send {
fn set_text(&mut self, selection: ClipboardSelection, text: Option<&str>)
-> Result<(), String>;

fn text(&mut self, selection: ClipboardSelection) -> Result<Option<String>, String>;
fn text(&mut self, selection: ClipboardSelection) -> Result<text_policy::TextRead, String>;

fn owner(&mut self, selection: ClipboardSelection) -> Result<SelectionOwner, String>;
}
Expand Down Expand Up @@ -186,7 +188,11 @@ fn execute_command(backend: &mut dyn ClipboardBackend, command: ClipboardCommand
ClipboardCommand::GetText {
selection, reply, ..
} => {
let result = backend.text(selection);
// The evaluator-facing wire carries text or absence; the two
// absence reasons stay inside the runtime (see `text_policy`).
let result = backend
.text(selection)
.map(text_policy::TextRead::into_option);
if let Err(err) = &result {
tracing::warn!(?selection, "clipboard read failed: {err}");
}
Expand Down Expand Up @@ -256,10 +262,12 @@ impl PrivateSelection {
};
}

fn load(&self) -> Option<String> {
/// The stored text, or the absence a vacant selection reports; going
/// through the policy keeps every "no owner" answer one vocabulary.
fn load(&self) -> text_policy::TextRead {
match self {
Self::Owned(text) => Some(text.clone()),
Self::Vacant => None,
Self::Owned(text) => text_policy::TextRead::Text(text.clone()),
Self::Vacant => text_policy::TextAbsence::NoSelection.into(),
}
}

Expand Down Expand Up @@ -289,11 +297,17 @@ impl ArboardClipboard {
.map_err(|err| format!("failed to initialize the system clipboard: {err}"))
}

fn text_result(result: Result<String, arboard::Error>) -> Result<Option<String>, String> {
/// Classify an arboard read into the clipboard's text vocabulary; this is
/// the adapter between arboard's error type and `text_policy`.
fn text_result(
result: Result<String, arboard::Error>,
) -> Result<text_policy::TextRead, String> {
match result {
Ok(text) => Ok(Some(text)),
Err(arboard::Error::ContentNotAvailable) => Ok(None),
Err(err) => Err(err.to_string()),
Ok(text) => Ok(text_policy::TextRead::Text(text)),
Err(err) => match text_policy::classify_arboard(&err) {
Some(failure) => Ok(text_policy::TextRead::from(failure)),
None => Err(err.to_string()),
},
}
}
}
Expand Down Expand Up @@ -336,7 +350,7 @@ impl ClipboardBackend for ArboardClipboard {
}
}

fn text(&mut self, selection: ClipboardSelection) -> Result<Option<String>, String> {
fn text(&mut self, selection: ClipboardSelection) -> Result<text_policy::TextRead, String> {
std::cfg_select! {
target_os = "linux" => {
let selection = match selection {
Expand Down Expand Up @@ -396,7 +410,10 @@ impl WaylandClipboard {
}
}

fn data_control_text(&mut self) -> Result<Option<String>, String> {
/// The fallback's answer, or `None` when this compositor has no
/// data-control (or the reader could not be bound); the caller keeps
/// smithay's classification in that case.
fn data_control_text(&mut self) -> Result<Option<text_policy::TextRead>, String> {
if let DataControl::Unbound = self.data_control {
self.data_control = match self.bind_data_control() {
Ok(Some(reader)) => DataControl::Bound(reader),
Expand All @@ -408,7 +425,7 @@ impl WaylandClipboard {
};
}
match &mut self.data_control {
DataControl::Bound(reader) => reader.read_text(),
DataControl::Bound(reader) => Ok(Some(reader.read_text()?)),
DataControl::Unbound | DataControl::Unavailable => Ok(None),
}
}
Expand All @@ -428,35 +445,6 @@ impl WaylandClipboard {
}
}

/// Whether smithay-clipboard saw no usable selection offer.
///
/// smithay-clipboard reports a missing offer as the untyped error
/// `selection is empty` and a missing text type as `NotFound`; both mean
/// "nothing to paste" rather than failure. Its other errors (no seat, no
/// keyboard focus) are real failures.
#[cfg(target_os = "linux")]
fn smithay_saw_no_offer(err: &std::io::Error) -> bool {
err.kind() == std::io::ErrorKind::NotFound || err.to_string() == "selection is empty"
}

/// Map a smithay-clipboard read result, consulting `fallback` only when it
/// saw no usable selection offer. A failed or timed-out fallback reads as
/// an empty selection (nil), not an error.
#[cfg(target_os = "linux")]
fn smithay_text_or_fallback(
result: std::io::Result<String>,
fallback: impl FnOnce() -> Result<Option<String>, String>,
) -> Result<Option<String>, String> {
match result {
Ok(text) => Ok(Some(text)),
Err(err) if smithay_saw_no_offer(&err) => fallback().or_else(|err| {
tracing::warn!("clipboard data-control read failed: {err}");
Ok(None)
}),
Err(err) => Err(err.to_string()),
}
}

#[cfg(target_os = "linux")]
impl ClipboardBackend for WaylandClipboard {
fn set_text(
Expand All @@ -479,18 +467,16 @@ impl ClipboardBackend for WaylandClipboard {
Ok(())
}

fn text(&mut self, selection: ClipboardSelection) -> Result<Option<String>, String> {
match selection {
ClipboardSelection::Clipboard => {
let result = self.clipboard.load();
smithay_text_or_fallback(result, || self.data_control_text())
}
// smithay-clipboard owns the only primary-selection device, so
// its result is complete.
ClipboardSelection::Primary => {
smithay_text_or_fallback(self.clipboard.load_primary(), || Ok(None))
}
}
fn text(&mut self, selection: ClipboardSelection) -> Result<text_policy::TextRead, String> {
// Hyprland delivers the selection only to winit's data device, so
// smithay's device can report an absence while the compositor holds a
// selection. Which selections may then consult the data-control
// fallback is `text_policy::wayland_read`'s business.
let native = match selection {
ClipboardSelection::Clipboard => self.clipboard.load(),
ClipboardSelection::Primary => self.clipboard.load_primary(),
};
text_policy::wayland_read(selection, native, || self.data_control_text())
}

fn owner(&mut self, _selection: ClipboardSelection) -> Result<SelectionOwner, String> {
Expand All @@ -501,3 +487,7 @@ impl ClipboardBackend for WaylandClipboard {
#[cfg(test)]
#[path = "clipboard/tests/clipboard_test.rs"]
mod tests;

#[cfg(test)]
#[path = "clipboard/tests/contract_test.rs"]
mod contract_tests;
111 changes: 26 additions & 85 deletions crates/neomacs-display-runtime/src/clipboard/tests/clipboard_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,11 @@ impl ClipboardBackend for MemoryClipboard {
Ok(())
}

fn text(&mut self, selection: ClipboardSelection) -> Result<Option<String>, String> {
Ok(self.selections.get(&selection).cloned())
fn text(&mut self, selection: ClipboardSelection) -> Result<text_policy::TextRead, String> {
Ok(match self.selections.get(&selection) {
Some(text) => text_policy::TextRead::Text(text.clone()),
None => text_policy::TextRead::NoSelection,
})
}

fn owner(&mut self, selection: ClipboardSelection) -> Result<SelectionOwner, String> {
Expand All @@ -59,10 +62,10 @@ impl ClipboardBackend for BlockingClipboard {
Ok(())
}

fn text(&mut self, _selection: ClipboardSelection) -> Result<Option<String>, String> {
fn text(&mut self, _selection: ClipboardSelection) -> Result<text_policy::TextRead, String> {
self.started.send(()).unwrap();
self.release.recv().unwrap();
Ok(Some("released".to_owned()))
Ok(text_policy::TextRead::Text("released".to_owned()))
}

fn owner(&mut self, _selection: ClipboardSelection) -> Result<SelectionOwner, String> {
Expand All @@ -79,8 +82,8 @@ impl ClipboardBackend for BlockingDropClipboard {
Ok(())
}

fn text(&mut self, _selection: ClipboardSelection) -> Result<Option<String>, String> {
Ok(None)
fn text(&mut self, _selection: ClipboardSelection) -> Result<text_policy::TextRead, String> {
Ok(text_policy::TextRead::NoSelection)
}

fn owner(&mut self, _selection: ClipboardSelection) -> Result<SelectionOwner, String> {
Expand All @@ -105,10 +108,10 @@ impl ClipboardBackend for ExpiringClipboard {
Ok(())
}

fn text(&mut self, _selection: ClipboardSelection) -> Result<Option<String>, String> {
fn text(&mut self, _selection: ClipboardSelection) -> Result<text_policy::TextRead, String> {
self.read_started.send(()).unwrap();
self.release_read.recv().unwrap();
Ok(None)
Ok(text_policy::TextRead::NoSelection)
}

fn owner(&mut self, _selection: ClipboardSelection) -> Result<SelectionOwner, String> {
Expand Down Expand Up @@ -293,16 +296,22 @@ fn mutation_that_expires_behind_a_slow_read_is_never_executed() {
#[test]
fn private_selection_round_trips_owned_and_vacant_states() {
let mut selection = PrivateSelection::default();
assert_eq!(selection.load(), None);
assert_eq!(selection.load(), text_policy::TextRead::NoSelection);

selection.store(Some("selected"));
assert_eq!(selection.load(), Some("selected".to_owned()));
assert_eq!(
selection.load(),
text_policy::TextRead::Text("selected".to_owned())
);

selection.store(Some("reselected"));
assert_eq!(selection.load(), Some("reselected".to_owned()));
assert_eq!(
selection.load(),
text_policy::TextRead::Text("reselected".to_owned())
);

selection.store(None);
assert_eq!(selection.load(), None);
assert_eq!(selection.load(), text_policy::TextRead::NoSelection);
}

/// GNU's NS port keeps PRIMARY in a private pasteboard instead of
Expand All @@ -319,82 +328,14 @@ fn arboard_backend_keeps_primary_in_process_local_state() {
.expect("PRIMARY store must not fail on this platform");
assert_eq!(
backend.text(ClipboardSelection::Primary),
Ok(Some("selected".to_owned()))
Ok(text_policy::TextRead::Text("selected".to_owned()))
);

backend
.set_text(ClipboardSelection::Primary, None)
.expect("PRIMARY disown must not fail on this platform");
assert_eq!(backend.text(ClipboardSelection::Primary), Ok(None));
}

#[cfg(target_os = "linux")]
mod wayland_fallback {
use super::super::smithay_text_or_fallback;
use std::cell::Cell;
use std::io;

fn no_offer() -> io::Result<String> {
Err(io::Error::other("selection is empty"))
}

#[test]
fn smithay_text_is_returned_without_consulting_data_control() {
let consulted = Cell::new(false);
let result = smithay_text_or_fallback(Ok("native".to_owned()), || {
consulted.set(true);
Ok(Some("data-control".to_owned()))
});
assert_eq!(result, Ok(Some("native".to_owned())));
assert!(!consulted.get());
}

#[test]
fn missing_selection_offer_reads_through_data_control() {
// Hyprland delivers the selection only to winit's data device, so
// smithay-clipboard's device never holds an offer.
let result = smithay_text_or_fallback(no_offer(), || Ok(Some("foreign".to_owned())));
assert_eq!(result, Ok(Some("foreign".to_owned())));
}

#[test]
fn missing_text_mime_reads_through_data_control() {
let result = smithay_text_or_fallback(
Err(io::Error::new(
io::ErrorKind::NotFound,
"supported mime-type is not found",
)),
|| Ok(Some("text from data-control".to_owned())),
);
assert_eq!(result, Ok(Some("text from data-control".to_owned())));
}

#[test]
fn empty_transfer_is_text_not_a_missing_selection() {
let result = smithay_text_or_fallback(Ok(String::new()), || {
panic!("an empty transfer must not consult data-control")
});
assert_eq!(result, Ok(Some(String::new())));
}

#[test]
fn seat_and_focus_errors_are_not_masked_by_data_control() {
for message in [
"client doesn't have focus",
"no events received on any seat",
] {
let result = smithay_text_or_fallback(Err(io::Error::other(message)), || {
panic!("{message} must not consult data-control")
});
assert_eq!(result, Err(message.to_owned()));
}
}

#[test]
fn failed_data_control_read_keeps_the_empty_result() {
let result = smithay_text_or_fallback(no_offer(), || {
Err("data-control transfer timed out".to_owned())
});
assert_eq!(result, Ok(None));
}
assert_eq!(
backend.text(ClipboardSelection::Primary),
Ok(text_policy::TextRead::NoSelection)
);
}
Loading
Loading