1#![forbid(unsafe_code)]
9#![warn(missing_docs)]
10
11use smallvec::SmallVec;
12use std::sync::Arc;
13
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub enum MimeKind {
21 TextPlain,
23 TextHtml,
25 TextUriList,
28 ImagePng,
30 ImageJpeg,
32 ApplicationOctet,
34 Custom(Arc<str>),
37}
38
39impl MimeKind {
40 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
75pub struct MimePayload {
76 pub kinds: SmallVec<[(MimeKind, Vec<u8>); 4]>,
79}
80
81impl MimePayload {
82 pub fn new() -> Self {
84 Self::default()
85 }
86
87 pub fn is_empty(&self) -> bool {
89 self.kinds.is_empty()
90 }
91
92 pub fn with(mut self, kind: MimeKind, bytes: Vec<u8>) -> Self {
95 self.kinds.push((kind, bytes));
96 self
97 }
98
99 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 pub fn has(&self, kind: &MimeKind) -> bool {
110 self.kinds.iter().any(|(k, _)| k == kind)
111 }
112
113 pub fn text(&self) -> Option<String> {
116 self.get(&MimeKind::TextPlain)
117 .map(|b| String::from_utf8_lossy(b).into_owned())
118 }
119
120 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 let mut buf = String::new();
165 for p in &paths {
166 if !buf.is_empty() {
167 buf.push_str("\r\n");
168 }
169 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#[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#[derive(Clone, Debug)]
210pub struct Action {
211 pub id: Arc<str>,
214 pub label: Arc<str>,
216 pub icon: Option<Arc<str>>,
218 pub shortcut: Option<KeyChord>,
220 pub enabled: bool,
222 pub checked: Option<bool>,
225}
226
227impl Action {
228 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 pub fn with_shortcut(mut self, chord: impl Into<KeyChord>) -> Self {
243 self.shortcut = Some(chord.into());
244 self
245 }
246
247 pub fn with_icon(mut self, icon: impl Into<Arc<str>>) -> Self {
249 self.icon = Some(icon.into());
250 self
251 }
252
253 pub fn enabled(mut self, on: bool) -> Self {
255 self.enabled = on;
256 self
257 }
258
259 pub fn checked(mut self, on: bool) -> Self {
262 self.checked = Some(on);
263 self
264 }
265}
266
267pub const SEPARATOR_ID: &str = "separator";
271
272impl From<&str> for Action {
273 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
288pub 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 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}