Skip to main content

lumen_os_mime/
lib.rs

1//! Shared `MimePayload` and `Action` abstractions for the `lumen-os-*`
2//! crates (`lumen-os-clipboard`, `lumen-os-dnd`, `lumen-os-menu`).
3//!
4//! Mirrors Qt's `QMimeData` / `QAction` and GTK 4's `GdkContentProvider`
5//! / `GAction`. Pure-data - no OS or ECS dependency lives here so every
6//! `lumen-os-*` crate can pull it without dragging in the others.
7
8#![forbid(unsafe_code)]
9#![warn(missing_docs)]
10
11use smallvec::SmallVec;
12use std::sync::Arc;
13
14/// A single MIME-typed payload entry kind.
15///
16/// Mirrors the well-known content types Qt's `QMimeData` exposes via
17/// `text()`, `html()`, `urls()`, `imageData()`, `setData(mime, bytes)`.
18/// Unknown / app-specific MIME types live under [`MimeKind::Custom`].
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub enum MimeKind {
21    /// `text/plain;charset=utf-8`.
22    TextPlain,
23    /// `text/html`.
24    TextHtml,
25    /// `text/uri-list` (RFC 2483) - newline-separated URIs / `file://`
26    /// paths. Standard format used by file managers for drag-and-drop.
27    TextUriList,
28    /// `image/png`.
29    ImagePng,
30    /// `image/jpeg`.
31    ImageJpeg,
32    /// `application/octet-stream` - opaque bytes.
33    ApplicationOctet,
34    /// Any other MIME type (`application/json`, `application/x-myapp`,
35    /// ...). The `Arc<str>` is shared so cheap to clone.
36    Custom(Arc<str>),
37}
38
39impl MimeKind {
40    /// Canonical MIME-type string for this kind.
41    pub fn as_str(&self) -> &str {
42        match self {
43            MimeKind::TextPlain => "text/plain;charset=utf-8",
44            MimeKind::TextHtml => "text/html",
45            MimeKind::TextUriList => "text/uri-list",
46            MimeKind::ImagePng => "image/png",
47            MimeKind::ImageJpeg => "image/jpeg",
48            MimeKind::ApplicationOctet => "application/octet-stream",
49            MimeKind::Custom(s) => s,
50        }
51    }
52}
53
54impl From<&str> for MimeKind {
55    fn from(s: &str) -> Self {
56        match s {
57            "text/plain" | "text/plain;charset=utf-8" => MimeKind::TextPlain,
58            "text/html" => MimeKind::TextHtml,
59            "text/uri-list" => MimeKind::TextUriList,
60            "image/png" => MimeKind::ImagePng,
61            "image/jpeg" => MimeKind::ImageJpeg,
62            "application/octet-stream" => MimeKind::ApplicationOctet,
63            other => MimeKind::Custom(Arc::from(other)),
64        }
65    }
66}
67
68/// A MIME-typed multi-format payload.
69///
70/// Used by both the clipboard ([`lumen-os-clipboard`]) and drag-and-drop
71/// ([`lumen-os-dnd`]) - one payload can carry multiple representations
72/// of the same data (the convention shared by `QMimeData` and
73/// `GdkContentProvider`). Targets read whichever MIME they understand.
74#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct MimePayload {
76    /// Stored representations in author-insertion order; recipients pick
77    /// the first kind they understand.
78    pub kinds: SmallVec<[(MimeKind, Vec<u8>); 4]>,
79}
80
81impl MimePayload {
82    /// Empty payload, no entries.
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    /// True when this payload carries no entries.
88    pub fn is_empty(&self) -> bool {
89        self.kinds.is_empty()
90    }
91
92    /// Push an `(kind, bytes)` entry. Returns `self` for builder-style
93    /// chaining.
94    pub fn with(mut self, kind: MimeKind, bytes: Vec<u8>) -> Self {
95        self.kinds.push((kind, bytes));
96        self
97    }
98
99    /// Look up the first entry matching `kind`. Returns `None` when not
100    /// carried.
101    pub fn get(&self, kind: &MimeKind) -> Option<&[u8]> {
102        self.kinds
103            .iter()
104            .find(|(k, _)| k == kind)
105            .map(|(_, b)| b.as_slice())
106    }
107
108    /// True when this payload carries the named kind.
109    pub fn has(&self, kind: &MimeKind) -> bool {
110        self.kinds.iter().any(|(k, _)| k == kind)
111    }
112
113    /// Convenience: read the `text/plain` entry as UTF-8, falling back
114    /// to an empty string when the bytes aren't valid UTF-8.
115    pub fn text(&self) -> Option<String> {
116        self.get(&MimeKind::TextPlain)
117            .map(|b| String::from_utf8_lossy(b).into_owned())
118    }
119
120    /// Convenience: read the `text/uri-list` entry, splitting on `\r\n`
121    /// per RFC 2483 (lines starting with `#` are comments and skipped).
122    pub fn uris(&self) -> Vec<String> {
123        let Some(bytes) = self.get(&MimeKind::TextUriList) else {
124            return Vec::new();
125        };
126        std::str::from_utf8(bytes)
127            .unwrap_or("")
128            .split("\r\n")
129            .filter(|s| !s.is_empty() && !s.starts_with('#'))
130            .map(|s| s.to_string())
131            .collect()
132    }
133}
134
135impl From<&str> for MimePayload {
136    fn from(s: &str) -> Self {
137        let mut p = MimePayload::new();
138        p.kinds.push((MimeKind::TextPlain, s.as_bytes().to_vec()));
139        p
140    }
141}
142
143impl From<String> for MimePayload {
144    fn from(s: String) -> Self {
145        let mut p = MimePayload::new();
146        p.kinds.push((MimeKind::TextPlain, s.into_bytes()));
147        p
148    }
149}
150
151impl From<Vec<u8>> for MimePayload {
152    fn from(bytes: Vec<u8>) -> Self {
153        let mut p = MimePayload::new();
154        p.kinds.push((MimeKind::ApplicationOctet, bytes));
155        p
156    }
157}
158
159impl From<Vec<std::path::PathBuf>> for MimePayload {
160    fn from(paths: Vec<std::path::PathBuf>) -> Self {
161        // text/uri-list is the standard DnD / clipboard format for file
162        // paths on every desktop OS (Win32 CF_HDROP gets translated
163        // through this on Wayland / X11 / macOS).
164        let mut buf = String::new();
165        for p in &paths {
166            if !buf.is_empty() {
167                buf.push_str("\r\n");
168            }
169            // Best-effort: percent-encoding is the receiver's problem
170            // here; most apps accept raw `file://` paths.
171            buf.push_str("file://");
172            buf.push_str(&p.to_string_lossy());
173        }
174        let mut payload = MimePayload::new();
175        payload
176            .kinds
177            .push((MimeKind::TextUriList, buf.into_bytes()));
178        payload
179    }
180}
181
182/// A keyboard-shortcut chord. Stored as a `muda` / `global-hotkey`-style
183/// accelerator string (e.g. `"Ctrl+S"`, `"Cmd+Shift+P"`).
184///
185/// Kept opaque so the menu / hotkey backends parse via their own crate
186/// API (`muda::accelerator::Accelerator::from_str`).
187#[derive(Clone, Debug, PartialEq, Eq, Hash)]
188pub struct KeyChord(pub Arc<str>);
189
190impl From<&str> for KeyChord {
191    fn from(s: &str) -> Self {
192        KeyChord(Arc::from(s))
193    }
194}
195
196impl From<String> for KeyChord {
197    fn from(s: String) -> Self {
198        KeyChord(Arc::from(s.as_str()))
199    }
200}
201
202/// A high-level user-invokable command. One `Action` ties together the
203/// menu item, toolbar button, and keyboard shortcut that all do the
204/// same thing - the model Qt's `QAction` and GTK 4's `GAction` use.
205///
206/// Lives in `ActionRegistry` (or a similar resource) per the OS plan
207/// (section 470). Backends translate the action into a menu item, hotkey, or
208/// toolbar button as appropriate.
209#[derive(Clone, Debug)]
210pub struct Action {
211    /// Stable id used to dispatch `ActionInvoked { id }` and to look the
212    /// action up across hot-reloads.
213    pub id: Arc<str>,
214    /// Human-visible label.
215    pub label: Arc<str>,
216    /// Optional icon identifier (asset path or theme name).
217    pub icon: Option<Arc<str>>,
218    /// Optional keyboard shortcut.
219    pub shortcut: Option<KeyChord>,
220    /// `true` when the action can be invoked.
221    pub enabled: bool,
222    /// `Some(true|false)` for a check / radio action; `None` for a
223    /// plain action.
224    pub checked: Option<bool>,
225}
226
227impl Action {
228    /// Construct a plain enabled action with no shortcut, icon, or
229    /// check state.
230    pub fn new(id: impl Into<Arc<str>>, label: impl Into<Arc<str>>) -> Self {
231        Self {
232            id: id.into(),
233            label: label.into(),
234            icon: None,
235            shortcut: None,
236            enabled: true,
237            checked: None,
238        }
239    }
240
241    /// Builder: set the keyboard shortcut.
242    pub fn with_shortcut(mut self, chord: impl Into<KeyChord>) -> Self {
243        self.shortcut = Some(chord.into());
244        self
245    }
246
247    /// Builder: set the icon.
248    pub fn with_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
249        self.icon = Some(icon.into());
250        self
251    }
252
253    /// Builder: set the enabled flag.
254    pub fn enabled(mut self, on: bool) -> Self {
255        self.enabled = on;
256        self
257    }
258
259    /// Builder: turn into a check action with the supplied initial
260    /// state.
261    pub fn checked(mut self, on: bool) -> Self {
262        self.checked = Some(on);
263        self
264    }
265}
266
267/// Action id reserved for a menu separator. A backend that can draw one
268/// (a tray menu, a menu bar) turns an action carrying this id into its
269/// platform separator.
270pub const SEPARATOR_ID: &str = "separator";
271
272impl From<&str> for Action {
273    /// Read one `id:label` entry of an action spec. An entry with no
274    /// `:` uses its whole text as both id and label, and a lone `-` is
275    /// the separator.
276    fn from(entry: &str) -> Self {
277        let entry = entry.trim();
278        if entry == "-" {
279            return Action::new(SEPARATOR_ID, "-");
280        }
281        match entry.split_once(':') {
282            Some((id, label)) => Action::new(id.trim(), label.trim()),
283            None => Action::new(entry, entry),
284        }
285    }
286}
287
288/// Read an action spec: `|`-separated `id:label` entries, where `-` is a
289/// separator.
290///
291/// One spelling serves every surface a script builds from a string, so an
292/// author learns the shape once and a tray menu and a notification's
293/// buttons read alike. Empty entries are skipped, which makes a trailing
294/// `|` harmless and an empty spec an empty list.
295pub fn parse_action_spec(spec: &str) -> Vec<Action> {
296    spec.split('|')
297        .map(str::trim)
298        .filter(|entry| !entry.is_empty())
299        .map(Action::from)
300        .collect()
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn key_chord_from_str_keeps_the_accelerator() {
309        let c: KeyChord = "Ctrl+S".into();
310        assert_eq!(c.0.as_ref(), "Ctrl+S");
311    }
312
313    #[test]
314    fn mime_payload_from_str_roundtrips_text() {
315        let p: MimePayload = "hello".into();
316        assert_eq!(p.text().as_deref(), Some("hello"));
317        assert!(p.has(&MimeKind::TextPlain));
318        assert!(!p.has(&MimeKind::TextHtml));
319    }
320
321    #[test]
322    fn mime_payload_from_bytes_is_octet() {
323        let p: MimePayload = vec![1u8, 2, 3].into();
324        assert_eq!(p.get(&MimeKind::ApplicationOctet), Some(&[1, 2, 3][..]));
325    }
326
327    #[test]
328    fn mime_payload_from_paths_emits_uri_list() {
329        let paths = vec![
330            std::path::PathBuf::from("/tmp/a.txt"),
331            std::path::PathBuf::from("/tmp/b.txt"),
332        ];
333        let p: MimePayload = paths.into();
334        let uris = p.uris();
335        assert_eq!(uris.len(), 2);
336        assert!(uris[0].starts_with("file:///tmp/a.txt"));
337        assert!(uris[1].starts_with("file:///tmp/b.txt"));
338    }
339
340    #[test]
341    fn mime_kind_from_str_canonicalises() {
342        assert_eq!(MimeKind::from("text/plain"), MimeKind::TextPlain);
343        assert_eq!(MimeKind::from("image/png"), MimeKind::ImagePng);
344        let custom = MimeKind::from("application/x-myapp");
345        match custom {
346            MimeKind::Custom(s) => assert_eq!(&*s, "application/x-myapp"),
347            _ => panic!("expected Custom variant"),
348        }
349    }
350
351    #[test]
352    fn action_builder_chain() {
353        let a = Action::new("file.save", "Save")
354            .with_shortcut("Ctrl+S")
355            .enabled(true);
356        assert_eq!(&*a.id, "file.save");
357        assert_eq!(&*a.label, "Save");
358        assert_eq!(a.shortcut.as_ref().map(|c| c.0.as_ref()), Some("Ctrl+S"));
359        assert!(a.enabled);
360        assert!(a.checked.is_none());
361    }
362
363    #[test]
364    fn action_check_state() {
365        let a = Action::new("view.bold", "Bold").checked(true);
366        assert_eq!(a.checked, Some(true));
367    }
368
369    #[test]
370    fn action_spec_reads_ids_labels_and_separators() {
371        let items = parse_action_spec("open:Open|-|quit: Quit ");
372        assert_eq!(items.len(), 3);
373        assert_eq!(&*items[0].id, "open");
374        assert_eq!(&*items[0].label, "Open");
375        assert_eq!(&*items[1].id, SEPARATOR_ID);
376        assert_eq!(&*items[2].id, "quit");
377        assert_eq!(&*items[2].label, "Quit");
378    }
379
380    #[test]
381    fn action_spec_tolerates_blanks_and_bare_entries() {
382        assert!(parse_action_spec("").is_empty());
383        assert!(parse_action_spec("  |  ").is_empty());
384        let items = parse_action_spec("Quit|");
385        assert_eq!(items.len(), 1);
386        assert_eq!(&*items[0].id, "Quit");
387        assert_eq!(&*items[0].label, "Quit");
388    }
389
390    #[test]
391    fn action_entry_keeps_a_windows_path_label_intact() {
392        // Only the first `:` splits, so a drive letter survives.
393        let a = Action::from(r"icon:C:\icons\app.png");
394        assert_eq!(&*a.id, "icon");
395        assert_eq!(&*a.label, r"C:\icons\app.png");
396    }
397
398    #[test]
399    fn empty_payload_has_no_text() {
400        let p = MimePayload::new();
401        assert!(p.is_empty());
402        assert_eq!(p.text(), None);
403        assert!(p.uris().is_empty());
404    }
405}