Skip to main content

gpui_platform/
clipboard.rs

1//! Clipboard and dragged-path data shared by `gpui` and its platform backends.
2
3use collections::FxBuildHasher;
4use seahash::SeaHasher;
5use serde::{Deserialize, Serialize};
6use smallvec::SmallVec;
7use std::{
8    hash::{BuildHasher, Hash, Hasher},
9    path::PathBuf,
10};
11use strum::EnumIter;
12
13/// A collection of paths from the platform, such as from a file drop.
14#[derive(Debug, Clone, Default, Eq, PartialEq)]
15pub struct ExternalPaths(pub SmallVec<[PathBuf; 2]>);
16
17impl ExternalPaths {
18    /// Convert this collection of paths into a slice.
19    pub fn paths(&self) -> &[PathBuf] {
20        &self.0
21    }
22}
23
24/// Data offered to the platform when an internal drag leaves the window and is
25/// promoted to a native drag session.
26#[derive(Debug, Clone, Eq, PartialEq)]
27pub enum ExternalDragPayload {
28    /// Real on-disk paths, handed to the platform as an outbound file drag.
29    Files(FileDragPaths),
30}
31
32/// Paths handed to the platform for a native file drag. Directory metadata is
33/// provided by the caller to avoid querying it when the platform drag starts.
34#[derive(Debug, Clone, Default, Eq, PartialEq)]
35pub struct FileDragPaths(SmallVec<[(PathBuf, bool); 2]>);
36
37impl FileDragPaths {
38    /// Creates a native file-drag payload from paths paired with whether each path is a directory.
39    pub fn new(entries: impl IntoIterator<Item = (PathBuf, bool)>) -> Self {
40        Self(entries.into_iter().collect())
41    }
42
43    /// The dragged paths, each paired with whether it is a directory.
44    pub fn entries(&self) -> &[(PathBuf, bool)] {
45        &self.0
46    }
47}
48
49/// Use a quick, non-cryptographically secure hash function to get an identifier from data
50pub fn hash<T: Hash>(data: &T) -> u64 {
51    FxBuildHasher.hash_one(data)
52}
53
54/// A clipboard item that should be copied to the clipboard
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct ClipboardItem {
57    /// The entries in this clipboard item.
58    pub entries: Vec<ClipboardEntry>,
59}
60
61/// An error produced by [`Platform::read_from_clipboard_async`](crate::Platform::read_from_clipboard_async).
62///
63/// Callers surface these failures to users, so the variants distinguish
64/// conditions that call for different user-facing guidance.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub enum ClipboardReadError {
67    /// The platform clipboard is not available in this context, e.g. the
68    /// browser does not expose the async clipboard API or the page is not a
69    /// secure context.
70    Unavailable,
71    /// The platform refused access, e.g. the user declined the browser's
72    /// clipboard permission prompt or paste confirmation.
73    Denied(String),
74    /// The clipboard contents could not be converted into a
75    /// [`ClipboardItem`].
76    UnsupportedContent,
77}
78
79impl std::fmt::Display for ClipboardReadError {
80    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        match self {
82            Self::Unavailable => formatter.write_str("the clipboard is unavailable"),
83            Self::Denied(message) => {
84                write!(formatter, "clipboard access was denied: {message}")
85            }
86            Self::UnsupportedContent => {
87                formatter.write_str("the clipboard contents are unsupported")
88            }
89        }
90    }
91}
92
93impl std::error::Error for ClipboardReadError {}
94
95/// Either a ClipboardString or a ClipboardImage
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub enum ClipboardEntry {
98    /// A string entry
99    String(ClipboardString),
100    /// An image entry
101    Image(Image),
102    /// A file entry
103    ExternalPaths(ExternalPaths),
104}
105
106impl ClipboardItem {
107    /// Create a new ClipboardItem::String with no associated metadata
108    pub fn new_string(text: String) -> Self {
109        Self {
110            entries: vec![ClipboardEntry::String(ClipboardString::new(text))],
111        }
112    }
113
114    /// Create a new ClipboardItem::String with the given text and associated metadata
115    pub fn new_string_with_metadata(text: String, metadata: String) -> Self {
116        Self {
117            entries: vec![ClipboardEntry::String(ClipboardString {
118                text,
119                metadata: Some(metadata),
120            })],
121        }
122    }
123
124    /// Create a new ClipboardItem::String with the given text and associated metadata
125    pub fn new_string_with_json_metadata<T: Serialize>(text: String, metadata: T) -> Self {
126        Self {
127            entries: vec![ClipboardEntry::String(
128                ClipboardString::new(text).with_json_metadata(metadata),
129            )],
130        }
131    }
132
133    /// Create a new ClipboardItem::Image with the given image with no associated metadata
134    pub fn new_image(image: &Image) -> Self {
135        Self {
136            entries: vec![ClipboardEntry::Image(image.clone())],
137        }
138    }
139
140    /// Concatenates together all the ClipboardString entries in the item.
141    /// Returns None if there were no ClipboardString entries.
142    pub fn text(&self) -> Option<String> {
143        let mut answer = String::new();
144
145        for entry in self.entries.iter() {
146            if let ClipboardEntry::String(ClipboardString { text, metadata: _ }) = entry {
147                answer.push_str(text);
148            }
149        }
150
151        if answer.is_empty() {
152            for entry in self.entries.iter() {
153                if let ClipboardEntry::ExternalPaths(paths) = entry {
154                    for path in &paths.0 {
155                        use std::fmt::Write as _;
156                        _ = write!(answer, "{}", path.display());
157                    }
158                }
159            }
160        }
161
162        if !answer.is_empty() {
163            Some(answer)
164        } else {
165            None
166        }
167    }
168
169    /// If this item is one ClipboardEntry::String, returns its metadata.
170    #[cfg_attr(not(target_os = "windows"), allow(dead_code))]
171    pub fn metadata(&self) -> Option<&String> {
172        match self.entries().first() {
173            Some(ClipboardEntry::String(clipboard_string)) if self.entries.len() == 1 => {
174                clipboard_string.metadata.as_ref()
175            }
176            _ => None,
177        }
178    }
179
180    /// Get the item's entries
181    pub fn entries(&self) -> &[ClipboardEntry] {
182        &self.entries
183    }
184
185    /// Get owned versions of the item's entries
186    pub fn into_entries(self) -> impl Iterator<Item = ClipboardEntry> {
187        self.entries.into_iter()
188    }
189}
190
191impl From<ClipboardString> for ClipboardEntry {
192    fn from(value: ClipboardString) -> Self {
193        Self::String(value)
194    }
195}
196
197impl From<String> for ClipboardEntry {
198    fn from(value: String) -> Self {
199        Self::from(ClipboardString::from(value))
200    }
201}
202
203impl From<Image> for ClipboardEntry {
204    fn from(value: Image) -> Self {
205        Self::Image(value)
206    }
207}
208
209impl From<ClipboardEntry> for ClipboardItem {
210    fn from(value: ClipboardEntry) -> Self {
211        Self {
212            entries: vec![value],
213        }
214    }
215}
216
217impl From<String> for ClipboardItem {
218    fn from(value: String) -> Self {
219        Self::from(ClipboardEntry::from(value))
220    }
221}
222
223impl From<Image> for ClipboardItem {
224    fn from(value: Image) -> Self {
225        Self::from(ClipboardEntry::from(value))
226    }
227}
228
229/// One of the editor's supported image formats (e.g. PNG, JPEG) - used when dealing with images in the clipboard
230#[derive(Clone, Copy, Debug, Eq, PartialEq, EnumIter, Hash)]
231pub enum ImageFormat {
232    // Sorted from most to least likely to be pasted into an editor,
233    // which matters when we iterate through them trying to see if
234    // clipboard content matches them.
235    /// .png
236    Png,
237    /// .jpeg or .jpg
238    Jpeg,
239    /// .webp
240    Webp,
241    /// .gif
242    Gif,
243    /// .svg
244    Svg,
245    /// .bmp
246    Bmp,
247    /// .tif or .tiff
248    Tiff,
249    /// .ico
250    Ico,
251    /// Netpbm image formats (.pbm, .ppm, .pgm).
252    Pnm,
253}
254
255impl ImageFormat {
256    /// Returns the mime type for the ImageFormat
257    pub const fn mime_type(self) -> &'static str {
258        match self {
259            ImageFormat::Png => "image/png",
260            ImageFormat::Jpeg => "image/jpeg",
261            ImageFormat::Webp => "image/webp",
262            ImageFormat::Gif => "image/gif",
263            ImageFormat::Svg => "image/svg+xml",
264            ImageFormat::Bmp => "image/bmp",
265            ImageFormat::Tiff => "image/tiff",
266            ImageFormat::Ico => "image/ico",
267            ImageFormat::Pnm => "image/x-portable-anymap",
268        }
269    }
270
271    /// Returns the file extension for this image format (without leading dot).
272    pub const fn extension(self) -> &'static str {
273        match self {
274            ImageFormat::Png => "png",
275            ImageFormat::Jpeg => "jpg",
276            ImageFormat::Webp => "webp",
277            ImageFormat::Gif => "gif",
278            ImageFormat::Svg => "svg",
279            ImageFormat::Bmp => "bmp",
280            ImageFormat::Tiff => "tiff",
281            ImageFormat::Ico => "ico",
282            ImageFormat::Pnm => "pnm",
283        }
284    }
285
286    /// Returns the ImageFormat for the given mime type, including known aliases.
287    pub fn from_mime_type(mime_type: &str) -> Option<Self> {
288        use strum::IntoEnumIterator;
289        Self::iter()
290            .find(|format| format.mime_type() == mime_type)
291            .or_else(|| Self::from_mime_type_alias(mime_type))
292    }
293
294    /// Non-canonical mime types that some producers use in the wild.
295    /// Unlike `mime_type()` which returns the single canonical form,
296    /// these are legacy or shortened variants we still need to recognize.
297    fn from_mime_type_alias(mime_type: &str) -> Option<Self> {
298        match mime_type {
299            "image/jpg" => Some(Self::Jpeg),
300            "image/tif" => Some(Self::Tiff),
301            _ => None,
302        }
303    }
304}
305
306/// An image, with a format and certain bytes
307#[derive(Clone, Debug, PartialEq, Eq)]
308pub struct Image {
309    /// The image format the bytes represent (e.g. PNG)
310    pub format: ImageFormat,
311    /// The raw image bytes
312    pub bytes: Vec<u8>,
313    /// The unique ID for the image
314    pub id: u64,
315}
316
317impl Hash for Image {
318    fn hash<H: Hasher>(&self, state: &mut H) {
319        state.write_u64(self.id);
320    }
321}
322
323impl Image {
324    /// An empty image containing no data
325    pub fn empty() -> Self {
326        Self::from_bytes(ImageFormat::Png, Vec::new())
327    }
328
329    /// Create an image from a format and bytes
330    pub fn from_bytes(format: ImageFormat, bytes: Vec<u8>) -> Self {
331        Self {
332            id: hash(&bytes),
333            format,
334            bytes,
335        }
336    }
337
338    /// Get this image's ID
339    pub fn id(&self) -> u64 {
340        self.id
341    }
342
343    /// Get the format of the clipboard image
344    pub fn format(&self) -> ImageFormat {
345        self.format
346    }
347
348    /// Get the raw bytes of the clipboard image
349    pub fn bytes(&self) -> &[u8] {
350        self.bytes.as_slice()
351    }
352}
353
354/// A clipboard item that should be copied to the clipboard
355#[derive(Clone, Debug, Eq, PartialEq)]
356pub struct ClipboardString {
357    /// The text content.
358    pub text: String,
359    /// Optional metadata associated with this clipboard string.
360    pub metadata: Option<String>,
361}
362
363impl ClipboardString {
364    /// Create a new clipboard string with the given text
365    pub fn new(text: String) -> Self {
366        Self {
367            text,
368            metadata: None,
369        }
370    }
371
372    /// Return a new clipboard item with the metadata replaced by the given metadata,
373    /// after serializing it as JSON.
374    pub fn with_json_metadata<T: Serialize>(mut self, metadata: T) -> Self {
375        self.metadata = Some(serde_json::to_string(&metadata).unwrap());
376        self
377    }
378
379    /// Get the text of the clipboard string
380    pub fn text(&self) -> &String {
381        &self.text
382    }
383
384    /// Get the owned text of the clipboard string
385    pub fn into_text(self) -> String {
386        self.text
387    }
388
389    /// Get the metadata of the clipboard string, formatted as JSON
390    pub fn metadata_json<T>(&self) -> Option<T>
391    where
392        T: for<'a> Deserialize<'a>,
393    {
394        self.metadata
395            .as_ref()
396            .and_then(|m| serde_json::from_str(m).ok())
397    }
398
399    #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))]
400    /// Compute a hash of the given text for clipboard change detection.
401    pub fn text_hash(text: &str) -> u64 {
402        let mut hasher = SeaHasher::new();
403        text.hash(&mut hasher);
404        hasher.finish()
405    }
406}
407
408impl From<String> for ClipboardString {
409    fn from(value: String) -> Self {
410        Self {
411            text: value,
412            metadata: None,
413        }
414    }
415}