Hold typed text over a stale commit, and paste a picture into an entry
A field painted from a commit rendered before the worker saw the latest keystroke put the older text back under the caret, and the next key landed on that. Events now carry a sequence number and each commit the last one settled before it rendered; what the window wrote back stands until a commit has caught up with it. Ctrl+V on a clipboard with no text leaves libcosmic's field unchanged. That paste now reads image/png off the clipboard and fires paste-empty, which glimmer-cosmic hands to :on-paste-empty; clipboard-image-png! collects it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1ee075a parent: 71cdc87 modified
crates/jolt-cosmic/src/lib.rs +178 -17 | @@ -26,7 +26,7 @@ pub use tree::{Node, Prop, Tree}; | ||
| 26 | 26 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 27 | 27 | use std::ffi::{c_char, c_int}; |
| 28 | 28 | use std::path::PathBuf; |
| 29 | -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering::SeqCst}; | |
| 29 | +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst}; | |
| 30 | 30 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; |
| 31 | 31 | use std::time::Duration; |
| 32 | 32 | |
| @@ -62,6 +62,9 @@ static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| { | ||
| 62 | 62 | |
| 63 | 63 | /// What `view` paints: the tree as of the last commit. |
| 64 | 64 | static COMMITTED: LazyLock<Mutex<Arc<Tree>>> = LazyLock::new(Default::default); |
| 65 | +/// The inbox's `settled` when that commit was made. Written under | |
| 66 | +/// `COMMITTED`'s lock, so the two are read as a pair. | |
| 67 | +static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0); | |
| 65 | 68 | |
| 66 | 69 | fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R { |
| 67 | 70 | let mut e = lock(&EDITS); |
| @@ -76,6 +79,7 @@ fn read<R>(f: impl FnOnce(&Tree) -> R) -> R { | ||
| 76 | 79 | // --- events, towards jolt ---------------------------------------------------- |
| 77 | 80 | |
| 78 | 81 | struct Event { |
| 82 | + seq: u64, | |
| 79 | 83 | node: i32, |
| 80 | 84 | name: &'static str, |
| 81 | 85 | text: String, |
| @@ -86,23 +90,47 @@ struct Inbox { | ||
| 86 | 90 | queue: VecDeque<Event>, |
| 87 | 91 | current: Option<Event>, |
| 88 | 92 | woken: bool, |
| 93 | + /// The sequence number of the last event posted. | |
| 94 | + posted: u64, | |
| 95 | + /// The sequence number of the last event the worker dequeued. | |
| 96 | + taken: u64, | |
| 97 | + /// `taken` as of the worker's last `cosmic_wait`. Every event up to here | |
| 98 | + /// had its handler run on an earlier pass, so whatever it re-rendered is | |
| 99 | + /// in the arena by the next commit. | |
| 100 | + settled: u64, | |
| 89 | 101 | } |
| 90 | 102 | |
| 91 | 103 | static INBOX: Mutex<Inbox> = Mutex::new(Inbox { |
| 92 | 104 | queue: VecDeque::new(), |
| 93 | 105 | current: None, |
| 94 | 106 | woken: false, |
| 107 | + posted: 0, | |
| 108 | + taken: 0, | |
| 109 | + settled: 0, | |
| 95 | 110 | }); |
| 96 | 111 | static BELL: Condvar = Condvar::new(); |
| 97 | 112 | |
| 98 | 113 | fn post(node: i32, name: &'static str, text: String, num: f64) { |
| 99 | - lock(&INBOX).queue.push_back(Event { | |
| 100 | - node, | |
| 101 | - name, | |
| 102 | - text, | |
| 103 | - num, | |
| 104 | - }); | |
| 114 | + post_seq(node, name, text, num); | |
| 115 | +} | |
| 116 | + | |
| 117 | +/// Queue an event for the worker; answers its sequence number. | |
| 118 | +fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 { | |
| 119 | + let seq = { | |
| 120 | + let mut inbox = lock(&INBOX); | |
| 121 | + inbox.posted += 1; | |
| 122 | + let seq = inbox.posted; | |
| 123 | + inbox.queue.push_back(Event { | |
| 124 | + seq, | |
| 125 | + node, | |
| 126 | + name, | |
| 127 | + text, | |
| 128 | + num, | |
| 129 | + }); | |
| 130 | + seq | |
| 131 | + }; | |
| 105 | 132 | BELL.notify_all(); |
| 133 | + seq | |
| 106 | 134 | } |
| 107 | 135 | |
| 108 | 136 | // --- wakes, towards iced ----------------------------------------------------- |
| @@ -133,6 +161,32 @@ enum Pick { | ||
| 133 | 161 | |
| 134 | 162 | static PICK: Mutex<Pick> = Mutex::new(Pick::Idle); |
| 135 | 163 | |
| 164 | +/// The picture a Ctrl+V found on the clipboard, held until the worker asks for | |
| 165 | +/// it with `cosmic_clipboard_image_png`. | |
| 166 | +static CLIPBOARD_PNG: Mutex<Option<Vec<u8>>> = Mutex::new(None); | |
| 167 | + | |
| 168 | +/// The clipboard read as PNG. Only image/png is asked for: every desktop that | |
| 169 | +/// puts a picture on a clipboard puts one there as PNG too. | |
| 170 | +struct ClipboardPng(Vec<u8>); | |
| 171 | + | |
| 172 | +impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng { | |
| 173 | + fn allowed() -> std::borrow::Cow<'static, [String]> { | |
| 174 | + std::borrow::Cow::Owned(vec!["image/png".to_owned()]) | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +impl TryFrom<(Vec<u8>, String)> for ClipboardPng { | |
| 179 | + type Error = (); | |
| 180 | + | |
| 181 | + fn try_from((bytes, _mime): (Vec<u8>, String)) -> Result<Self, ()> { | |
| 182 | + if bytes.is_empty() { | |
| 183 | + Err(()) | |
| 184 | + } else { | |
| 185 | + Ok(Self(bytes)) | |
| 186 | + } | |
| 187 | + } | |
| 188 | +} | |
| 189 | + | |
| 136 | 190 | /// Named for emoji rather than left to fallback: the first face with a glyph |
| 137 | 191 | /// for a smiley is often a monochrome one, and the pill then shows an outline. |
| 138 | 192 | const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji"); |
| @@ -271,6 +325,9 @@ struct App { | ||
| 271 | 325 | core: Core, |
| 272 | 326 | tree: Arc<Tree>, |
| 273 | 327 | scrolls: HashMap<String, ScrollMemo>, |
| 328 | + /// What the window last wrote back into a control, by node and prop, with | |
| 329 | + /// the sequence number of the event that carried it to the worker. | |
| 330 | + typed: HashMap<(i32, &'static str), (u64, Prop)>, | |
| 274 | 331 | } |
| 275 | 332 | |
| 276 | 333 | #[derive(Clone, Debug)] |
| @@ -280,6 +337,8 @@ enum Message { | ||
| 280 | 337 | Click(i32), |
| 281 | 338 | Toggled(i32, bool), |
| 282 | 339 | Change(i32, String), |
| 340 | + Paste(i32, String), | |
| 341 | + PastedPicture(i32, Option<Vec<u8>>), | |
| 283 | 342 | Activate(i32), |
| 284 | 343 | Hover(i32), |
| 285 | 344 | Unhover(i32), |
| @@ -288,13 +347,37 @@ enum Message { | ||
| 288 | 347 | Picked(Option<PathBuf>), |
| 289 | 348 | } |
| 290 | 349 | |
| 350 | +/// Lay what was typed over a commit that has not caught up with it. | |
| 351 | +/// | |
| 352 | +/// libcosmic paints a control from the tree, so a commit rendered before the | |
| 353 | +/// worker saw the latest keystroke would put the older text back under the | |
| 354 | +/// caret, and the next key would land on that. An entry is let go once a | |
| 355 | +/// commit was rendered after its event: from then on the component's own | |
| 356 | +/// state is the answer, a draft it cleared included. | |
| 357 | +fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) { | |
| 358 | + typed.retain(|&(node, key), (seq, value)| { | |
| 359 | + let Some(n) = tree.get(node) else { return false }; | |
| 360 | + if *seq <= settled { | |
| 361 | + return false; | |
| 362 | + } | |
| 363 | + if n.props.get(key) != Some(value) { | |
| 364 | + Arc::make_mut(tree).set(node, key, value.clone()); | |
| 365 | + } | |
| 366 | + true | |
| 367 | + }); | |
| 368 | +} | |
| 369 | + | |
| 291 | 370 | impl App { |
| 292 | 371 | /// A widget does not own its value: the new state goes into the arena and |
| 293 | 372 | /// into what is painted, so a caller that ignores the event still sees a |
| 294 | - /// working control, and its next render is what settles it. | |
| 295 | - fn write_back(&mut self, node: i32, key: &str, value: Prop) { | |
| 373 | + /// working control, and its next render is what settles it. Then the event | |
| 374 | + /// goes to the worker, and what was written is held over any commit | |
| 375 | + /// rendered before the worker saw it. | |
| 376 | + fn write_back(&mut self, node: i32, key: &'static str, value: Prop, event: &'static str, text: String, num: f64) { | |
| 296 | 377 | edit(|t| t.set(node, key, value.clone())); |
| 297 | - Arc::make_mut(&mut self.tree).set(node, key, value); | |
| 378 | + Arc::make_mut(&mut self.tree).set(node, key, value.clone()); | |
| 379 | + let seq = post_seq(node, event, text, num); | |
| 380 | + self.typed.insert((node, key), (seq, value)); | |
| 298 | 381 | } |
| 299 | 382 | |
| 300 | 383 | /// Take the committed tree, and move every scroll area to where it should |
| @@ -303,7 +386,12 @@ impl App { | ||
| 303 | 386 | /// A snap is relative, so a list snapped to its end stays at its end as |
| 304 | 387 | /// rows arrive under it, until the reader scrolls away. |
| 305 | 388 | fn take_tree(&mut self) -> Task<Message> { |
| 306 | - let before = std::mem::replace(&mut self.tree, lock(&COMMITTED).clone()); | |
| 389 | + let (committed, settled) = { | |
| 390 | + let c = lock(&COMMITTED); | |
| 391 | + (c.clone(), COMMITTED_SETTLED.load(SeqCst)) | |
| 392 | + }; | |
| 393 | + let before = std::mem::replace(&mut self.tree, committed); | |
| 394 | + keep_typed(&mut self.tree, &mut self.typed, settled); | |
| 307 | 395 | let mut tasks = Vec::new(); |
| 308 | 396 | let mut live = HashSet::new(); |
| 309 | 397 | for ask in scroll_asks(&before, &self.tree) { |
| @@ -378,6 +466,7 @@ impl cosmic::Application for App { | ||
| 378 | 466 | core, |
| 379 | 467 | tree: lock(&COMMITTED).clone(), |
| 380 | 468 | scrolls: HashMap::new(), |
| 469 | + typed: HashMap::new(), | |
| 381 | 470 | }; |
| 382 | 471 | // libcosmic's `wayland` feature brings `multi-window` with it, which |
| 383 | 472 | // makes a window title a per-window thing. |
| @@ -404,12 +493,27 @@ impl cosmic::Application for App { | ||
| 404 | 493 | Message::Quit => return cosmic::iced::exit(), |
| 405 | 494 | Message::Click(node) => post(node, "click", String::new(), 0.0), |
| 406 | 495 | Message::Toggled(node, on) => { |
| 407 | - self.write_back(node, "active", Prop::Bool(on)); | |
| 408 | - post(node, "toggled", String::new(), f64::from(u8::from(on))); | |
| 496 | + let num = f64::from(u8::from(on)); | |
| 497 | + self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num); | |
| 409 | 498 | } |
| 410 | 499 | Message::Change(node, text) => { |
| 411 | - self.write_back(node, "text", Prop::Str(text.clone())); | |
| 412 | - post(node, "change", text, 0.0); | |
| 500 | + self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0); | |
| 501 | + } | |
| 502 | + // libcosmic's field answers Ctrl+V with the clipboard's text, and a | |
| 503 | + // clipboard holding a picture has none, so the field comes back as | |
| 504 | + // it was. That is the paste worth reporting: the picture is read | |
| 505 | + // here, where the clipboard is, and `paste-empty` goes to the | |
| 506 | + // worker, which collects it with `cosmic_clipboard_image_png`. | |
| 507 | + Message::Paste(node, text) => { | |
| 508 | + if self.tree.get(node).is_some_and(|n| n.str("text") == text) { | |
| 509 | + return cosmic::iced::clipboard::read_data::<ClipboardPng>() | |
| 510 | + .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0)))); | |
| 511 | + } | |
| 512 | + self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0); | |
| 513 | + } | |
| 514 | + Message::PastedPicture(node, png) => { | |
| 515 | + *lock(&CLIPBOARD_PNG) = png; | |
| 516 | + post(node, "paste-empty", String::new(), 0.0); | |
| 413 | 517 | } |
| 414 | 518 | Message::Activate(node) => post(node, "activate", String::new(), 0.0), |
| 415 | 519 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), |
| @@ -808,6 +912,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | ||
| 808 | 912 | if enabled { |
| 809 | 913 | entry = entry |
| 810 | 914 | .on_input(move |text| Message::Change(id, text)) |
| 915 | + .on_paste(move |text| Message::Paste(id, text)) | |
| 811 | 916 | .on_submit(move |_| Message::Activate(id)); |
| 812 | 917 | } |
| 813 | 918 | let width = match width_request(n) { |
| @@ -927,15 +1032,22 @@ pub extern "C" fn cosmic_quit() { | ||
| 927 | 1032 | #[no_mangle] |
| 928 | 1033 | pub extern "C" fn cosmic_tree_commit() -> c_int { |
| 929 | 1034 | guard(0, || { |
| 1035 | + let settled = lock(&INBOX).settled; | |
| 930 | 1036 | let snapshot = { |
| 931 | 1037 | let mut e = lock(&EDITS); |
| 932 | - if !e.dirty { | |
| 1038 | + // A pass that only settled events still publishes, so a control | |
| 1039 | + // holding typed text over an older commit lets go of it. | |
| 1040 | + if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) { | |
| 933 | 1041 | return 0; |
| 934 | 1042 | } |
| 935 | 1043 | e.dirty = false; |
| 936 | 1044 | Arc::new(e.tree.clone()) |
| 937 | 1045 | }; |
| 938 | - *lock(&COMMITTED) = snapshot; | |
| 1046 | + { | |
| 1047 | + let mut committed = lock(&COMMITTED); | |
| 1048 | + *committed = snapshot; | |
| 1049 | + COMMITTED_SETTLED.store(settled, SeqCst); | |
| 1050 | + } | |
| 939 | 1051 | tell_app(Wake::Tree); |
| 940 | 1052 | 1 |
| 941 | 1053 | }) |
| @@ -953,6 +1065,7 @@ pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int { | ||
| 953 | 1065 | }) |
| 954 | 1066 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 955 | 1067 | inbox.woken = false; |
| 1068 | + inbox.settled = inbox.taken; | |
| 956 | 1069 | c_int::from(!inbox.queue.is_empty()) |
| 957 | 1070 | }) |
| 958 | 1071 | } |
| @@ -1024,6 +1137,29 @@ pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int { | ||
| 1024 | 1137 | }) |
| 1025 | 1138 | } |
| 1026 | 1139 | |
| 1140 | +/// Write the picture the last empty Ctrl+V found on the clipboard to `path`. | |
| 1141 | +/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was | |
| 1142 | +/// already taken, or when the file could not be written. | |
| 1143 | +/// | |
| 1144 | +/// # Safety | |
| 1145 | +/// `path` is null or a NUL-terminated string. | |
| 1146 | +#[no_mangle] | |
| 1147 | +pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int { | |
| 1148 | + let path = borrowed(path); | |
| 1149 | + guard(0, || { | |
| 1150 | + let Some(png) = lock(&CLIPBOARD_PNG).take() else { | |
| 1151 | + return 0; | |
| 1152 | + }; | |
| 1153 | + match std::fs::write(&*path, png) { | |
| 1154 | + Ok(()) => 1, | |
| 1155 | + Err(err) => { | |
| 1156 | + eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}"); | |
| 1157 | + 0 | |
| 1158 | + } | |
| 1159 | + } | |
| 1160 | + }) | |
| 1161 | +} | |
| 1162 | + | |
| 1027 | 1163 | // --- the C ABI: events ----------------------------------------------------------- |
| 1028 | 1164 | |
| 1029 | 1165 | static EVENT_NAME: Scratch = Scratch::new(); |
| @@ -1036,6 +1172,9 @@ pub extern "C" fn cosmic_tree_poll_event() -> c_int { | ||
| 1036 | 1172 | let mut inbox = lock(&INBOX); |
| 1037 | 1173 | let next = inbox.queue.pop_front(); |
| 1038 | 1174 | let got = next.is_some(); |
| 1175 | + if let Some(e) = &next { | |
| 1176 | + inbox.taken = e.seq; | |
| 1177 | + } | |
| 1039 | 1178 | inbox.current = next; |
| 1040 | 1179 | c_int::from(got) |
| 1041 | 1180 | }) |
| @@ -1204,6 +1343,28 @@ mod tests { | ||
| 1204 | 1343 | id |
| 1205 | 1344 | } |
| 1206 | 1345 | |
| 1346 | + #[test] | |
| 1347 | + fn typed_text_stands_over_a_commit_that_has_not_seen_it() { | |
| 1348 | + let mut t = Tree::default(); | |
| 1349 | + let root = t.root(); | |
| 1350 | + let entry = node(&mut t, root, "entry"); | |
| 1351 | + t.set(entry, "text", Prop::Str("a".into())); | |
| 1352 | + let mut tree = Arc::new(t); | |
| 1353 | + let mut typed = HashMap::new(); | |
| 1354 | + typed.insert((entry, "text"), (2, Prop::Str("ab".into()))); | |
| 1355 | + | |
| 1356 | + // Rendered before the worker saw the "b". | |
| 1357 | + keep_typed(&mut tree, &mut typed, 1); | |
| 1358 | + assert_eq!(tree.get(entry).unwrap().str("text"), "ab"); | |
| 1359 | + assert_eq!(typed.len(), 1); | |
| 1360 | + | |
| 1361 | + // Rendered after: the component cleared its draft, and that stands. | |
| 1362 | + Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new())); | |
| 1363 | + keep_typed(&mut tree, &mut typed, 2); | |
| 1364 | + assert_eq!(tree.get(entry).unwrap().str("text"), ""); | |
| 1365 | + assert!(typed.is_empty()); | |
| 1366 | + } | |
| 1367 | + | |
| 1207 | 1368 | #[test] |
| 1208 | 1369 | fn a_zero_width_request_is_no_request() { |
| 1209 | 1370 | let mut t = Tree::default(); |
| @@ -26,7 +26,7 @@ pub use tree::{Node, Prop, Tree}; | |||
| 26 | use std::collections::{HashMap, HashSet, VecDeque}; | 26 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 27 | use std::ffi::{c_char, c_int}; | 27 | use std::ffi::{c_char, c_int}; |
| 28 | use std::path::PathBuf; | 28 | use std::path::PathBuf; |
| 29 | -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering::SeqCst}; | 29 | +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst}; |
| 30 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; | 30 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; |
| 31 | use std::time::Duration; | 31 | use std::time::Duration; |
| 32 | 32 | ||
| @@ -62,6 +62,9 @@ static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| { | |||
| 62 | 62 | ||
| 63 | /// What `view` paints: the tree as of the last commit. | 63 | /// What `view` paints: the tree as of the last commit. |
| 64 | static COMMITTED: LazyLock<Mutex<Arc<Tree>>> = LazyLock::new(Default::default); | 64 | static COMMITTED: LazyLock<Mutex<Arc<Tree>>> = LazyLock::new(Default::default); |
| 65 | +/// The inbox's `settled` when that commit was made. Written under | ||
| 66 | +/// `COMMITTED`'s lock, so the two are read as a pair. | ||
| 67 | +static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0); | ||
| 65 | 68 | ||
| 66 | fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R { | 69 | fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R { |
| 67 | let mut e = lock(&EDITS); | 70 | let mut e = lock(&EDITS); |
| @@ -76,6 +79,7 @@ fn read<R>(f: impl FnOnce(&Tree) -> R) -> R { | |||
| 76 | // --- events, towards jolt ---------------------------------------------------- | 79 | // --- events, towards jolt ---------------------------------------------------- |
| 77 | 80 | ||
| 78 | struct Event { | 81 | struct Event { |
| 82 | + seq: u64, | ||
| 79 | node: i32, | 83 | node: i32, |
| 80 | name: &'static str, | 84 | name: &'static str, |
| 81 | text: String, | 85 | text: String, |
| @@ -86,23 +90,47 @@ struct Inbox { | |||
| 86 | queue: VecDeque<Event>, | 90 | queue: VecDeque<Event>, |
| 87 | current: Option<Event>, | 91 | current: Option<Event>, |
| 88 | woken: bool, | 92 | woken: bool, |
| 93 | + /// The sequence number of the last event posted. | ||
| 94 | + posted: u64, | ||
| 95 | + /// The sequence number of the last event the worker dequeued. | ||
| 96 | + taken: u64, | ||
| 97 | + /// `taken` as of the worker's last `cosmic_wait`. Every event up to here | ||
| 98 | + /// had its handler run on an earlier pass, so whatever it re-rendered is | ||
| 99 | + /// in the arena by the next commit. | ||
| 100 | + settled: u64, | ||
| 89 | } | 101 | } |
| 90 | 102 | ||
| 91 | static INBOX: Mutex<Inbox> = Mutex::new(Inbox { | 103 | static INBOX: Mutex<Inbox> = Mutex::new(Inbox { |
| 92 | queue: VecDeque::new(), | 104 | queue: VecDeque::new(), |
| 93 | current: None, | 105 | current: None, |
| 94 | woken: false, | 106 | woken: false, |
| 107 | + posted: 0, | ||
| 108 | + taken: 0, | ||
| 109 | + settled: 0, | ||
| 95 | }); | 110 | }); |
| 96 | static BELL: Condvar = Condvar::new(); | 111 | static BELL: Condvar = Condvar::new(); |
| 97 | 112 | ||
| 98 | fn post(node: i32, name: &'static str, text: String, num: f64) { | 113 | fn post(node: i32, name: &'static str, text: String, num: f64) { |
| 99 | - lock(&INBOX).queue.push_back(Event { | 114 | + post_seq(node, name, text, num); |
| 100 | - node, | 115 | +} |
| 101 | - name, | 116 | + |
| 102 | - text, | 117 | +/// Queue an event for the worker; answers its sequence number. |
| 103 | - num, | 118 | +fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 { |
| 104 | - }); | 119 | + let seq = { |
| 120 | + let mut inbox = lock(&INBOX); | ||
| 121 | + inbox.posted += 1; | ||
| 122 | + let seq = inbox.posted; | ||
| 123 | + inbox.queue.push_back(Event { | ||
| 124 | + seq, | ||
| 125 | + node, | ||
| 126 | + name, | ||
| 127 | + text, | ||
| 128 | + num, | ||
| 129 | + }); | ||
| 130 | + seq | ||
| 131 | + }; | ||
| 105 | BELL.notify_all(); | 132 | BELL.notify_all(); |
| 133 | + seq | ||
| 106 | } | 134 | } |
| 107 | 135 | ||
| 108 | // --- wakes, towards iced ----------------------------------------------------- | 136 | // --- wakes, towards iced ----------------------------------------------------- |
| @@ -133,6 +161,32 @@ enum Pick { | |||
| 133 | 161 | ||
| 134 | static PICK: Mutex<Pick> = Mutex::new(Pick::Idle); | 162 | static PICK: Mutex<Pick> = Mutex::new(Pick::Idle); |
| 135 | 163 | ||
| 164 | +/// The picture a Ctrl+V found on the clipboard, held until the worker asks for | ||
| 165 | +/// it with `cosmic_clipboard_image_png`. | ||
| 166 | +static CLIPBOARD_PNG: Mutex<Option<Vec<u8>>> = Mutex::new(None); | ||
| 167 | + | ||
| 168 | +/// The clipboard read as PNG. Only image/png is asked for: every desktop that | ||
| 169 | +/// puts a picture on a clipboard puts one there as PNG too. | ||
| 170 | +struct ClipboardPng(Vec<u8>); | ||
| 171 | + | ||
| 172 | +impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng { | ||
| 173 | + fn allowed() -> std::borrow::Cow<'static, [String]> { | ||
| 174 | + std::borrow::Cow::Owned(vec!["image/png".to_owned()]) | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +impl TryFrom<(Vec<u8>, String)> for ClipboardPng { | ||
| 179 | + type Error = (); | ||
| 180 | + | ||
| 181 | + fn try_from((bytes, _mime): (Vec<u8>, String)) -> Result<Self, ()> { | ||
| 182 | + if bytes.is_empty() { | ||
| 183 | + Err(()) | ||
| 184 | + } else { | ||
| 185 | + Ok(Self(bytes)) | ||
| 186 | + } | ||
| 187 | + } | ||
| 188 | +} | ||
| 189 | + | ||
| 136 | /// Named for emoji rather than left to fallback: the first face with a glyph | 190 | /// Named for emoji rather than left to fallback: the first face with a glyph |
| 137 | /// for a smiley is often a monochrome one, and the pill then shows an outline. | 191 | /// for a smiley is often a monochrome one, and the pill then shows an outline. |
| 138 | const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji"); | 192 | const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji"); |
| @@ -271,6 +325,9 @@ struct App { | |||
| 271 | core: Core, | 325 | core: Core, |
| 272 | tree: Arc<Tree>, | 326 | tree: Arc<Tree>, |
| 273 | scrolls: HashMap<String, ScrollMemo>, | 327 | scrolls: HashMap<String, ScrollMemo>, |
| 328 | + /// What the window last wrote back into a control, by node and prop, with | ||
| 329 | + /// the sequence number of the event that carried it to the worker. | ||
| 330 | + typed: HashMap<(i32, &'static str), (u64, Prop)>, | ||
| 274 | } | 331 | } |
| 275 | 332 | ||
| 276 | #[derive(Clone, Debug)] | 333 | #[derive(Clone, Debug)] |
| @@ -280,6 +337,8 @@ enum Message { | |||
| 280 | Click(i32), | 337 | Click(i32), |
| 281 | Toggled(i32, bool), | 338 | Toggled(i32, bool), |
| 282 | Change(i32, String), | 339 | Change(i32, String), |
| 340 | + Paste(i32, String), | ||
| 341 | + PastedPicture(i32, Option<Vec<u8>>), | ||
| 283 | Activate(i32), | 342 | Activate(i32), |
| 284 | Hover(i32), | 343 | Hover(i32), |
| 285 | Unhover(i32), | 344 | Unhover(i32), |
| @@ -288,13 +347,37 @@ enum Message { | |||
| 288 | Picked(Option<PathBuf>), | 347 | Picked(Option<PathBuf>), |
| 289 | } | 348 | } |
| 290 | 349 | ||
| 350 | +/// Lay what was typed over a commit that has not caught up with it. | ||
| 351 | +/// | ||
| 352 | +/// libcosmic paints a control from the tree, so a commit rendered before the | ||
| 353 | +/// worker saw the latest keystroke would put the older text back under the | ||
| 354 | +/// caret, and the next key would land on that. An entry is let go once a | ||
| 355 | +/// commit was rendered after its event: from then on the component's own | ||
| 356 | +/// state is the answer, a draft it cleared included. | ||
| 357 | +fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) { | ||
| 358 | + typed.retain(|&(node, key), (seq, value)| { | ||
| 359 | + let Some(n) = tree.get(node) else { return false }; | ||
| 360 | + if *seq <= settled { | ||
| 361 | + return false; | ||
| 362 | + } | ||
| 363 | + if n.props.get(key) != Some(value) { | ||
| 364 | + Arc::make_mut(tree).set(node, key, value.clone()); | ||
| 365 | + } | ||
| 366 | + true | ||
| 367 | + }); | ||
| 368 | +} | ||
| 369 | + | ||
| 291 | impl App { | 370 | impl App { |
| 292 | /// A widget does not own its value: the new state goes into the arena and | 371 | /// A widget does not own its value: the new state goes into the arena and |
| 293 | /// into what is painted, so a caller that ignores the event still sees a | 372 | /// into what is painted, so a caller that ignores the event still sees a |
| 294 | - /// working control, and its next render is what settles it. | 373 | + /// working control, and its next render is what settles it. Then the event |
| 295 | - fn write_back(&mut self, node: i32, key: &str, value: Prop) { | 374 | + /// goes to the worker, and what was written is held over any commit |
| 375 | + /// rendered before the worker saw it. | ||
| 376 | + fn write_back(&mut self, node: i32, key: &'static str, value: Prop, event: &'static str, text: String, num: f64) { | ||
| 296 | edit(|t| t.set(node, key, value.clone())); | 377 | edit(|t| t.set(node, key, value.clone())); |
| 297 | - Arc::make_mut(&mut self.tree).set(node, key, value); | 378 | + Arc::make_mut(&mut self.tree).set(node, key, value.clone()); |
| 379 | + let seq = post_seq(node, event, text, num); | ||
| 380 | + self.typed.insert((node, key), (seq, value)); | ||
| 298 | } | 381 | } |
| 299 | 382 | ||
| 300 | /// Take the committed tree, and move every scroll area to where it should | 383 | /// Take the committed tree, and move every scroll area to where it should |
| @@ -303,7 +386,12 @@ impl App { | |||
| 303 | /// A snap is relative, so a list snapped to its end stays at its end as | 386 | /// A snap is relative, so a list snapped to its end stays at its end as |
| 304 | /// rows arrive under it, until the reader scrolls away. | 387 | /// rows arrive under it, until the reader scrolls away. |
| 305 | fn take_tree(&mut self) -> Task<Message> { | 388 | fn take_tree(&mut self) -> Task<Message> { |
| 306 | - let before = std::mem::replace(&mut self.tree, lock(&COMMITTED).clone()); | 389 | + let (committed, settled) = { |
| 390 | + let c = lock(&COMMITTED); | ||
| 391 | + (c.clone(), COMMITTED_SETTLED.load(SeqCst)) | ||
| 392 | + }; | ||
| 393 | + let before = std::mem::replace(&mut self.tree, committed); | ||
| 394 | + keep_typed(&mut self.tree, &mut self.typed, settled); | ||
| 307 | let mut tasks = Vec::new(); | 395 | let mut tasks = Vec::new(); |
| 308 | let mut live = HashSet::new(); | 396 | let mut live = HashSet::new(); |
| 309 | for ask in scroll_asks(&before, &self.tree) { | 397 | for ask in scroll_asks(&before, &self.tree) { |
| @@ -378,6 +466,7 @@ impl cosmic::Application for App { | |||
| 378 | core, | 466 | core, |
| 379 | tree: lock(&COMMITTED).clone(), | 467 | tree: lock(&COMMITTED).clone(), |
| 380 | scrolls: HashMap::new(), | 468 | scrolls: HashMap::new(), |
| 469 | + typed: HashMap::new(), | ||
| 381 | }; | 470 | }; |
| 382 | // libcosmic's `wayland` feature brings `multi-window` with it, which | 471 | // libcosmic's `wayland` feature brings `multi-window` with it, which |
| 383 | // makes a window title a per-window thing. | 472 | // makes a window title a per-window thing. |
| @@ -404,12 +493,27 @@ impl cosmic::Application for App { | |||
| 404 | Message::Quit => return cosmic::iced::exit(), | 493 | Message::Quit => return cosmic::iced::exit(), |
| 405 | Message::Click(node) => post(node, "click", String::new(), 0.0), | 494 | Message::Click(node) => post(node, "click", String::new(), 0.0), |
| 406 | Message::Toggled(node, on) => { | 495 | Message::Toggled(node, on) => { |
| 407 | - self.write_back(node, "active", Prop::Bool(on)); | 496 | + let num = f64::from(u8::from(on)); |
| 408 | - post(node, "toggled", String::new(), f64::from(u8::from(on))); | 497 | + self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num); |
| 409 | } | 498 | } |
| 410 | Message::Change(node, text) => { | 499 | Message::Change(node, text) => { |
| 411 | - self.write_back(node, "text", Prop::Str(text.clone())); | 500 | + self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0); |
| 412 | - post(node, "change", text, 0.0); | 501 | + } |
| 502 | + // libcosmic's field answers Ctrl+V with the clipboard's text, and a | ||
| 503 | + // clipboard holding a picture has none, so the field comes back as | ||
| 504 | + // it was. That is the paste worth reporting: the picture is read | ||
| 505 | + // here, where the clipboard is, and `paste-empty` goes to the | ||
| 506 | + // worker, which collects it with `cosmic_clipboard_image_png`. | ||
| 507 | + Message::Paste(node, text) => { | ||
| 508 | + if self.tree.get(node).is_some_and(|n| n.str("text") == text) { | ||
| 509 | + return cosmic::iced::clipboard::read_data::<ClipboardPng>() | ||
| 510 | + .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0)))); | ||
| 511 | + } | ||
| 512 | + self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0); | ||
| 513 | + } | ||
| 514 | + Message::PastedPicture(node, png) => { | ||
| 515 | + *lock(&CLIPBOARD_PNG) = png; | ||
| 516 | + post(node, "paste-empty", String::new(), 0.0); | ||
| 413 | } | 517 | } |
| 414 | Message::Activate(node) => post(node, "activate", String::new(), 0.0), | 518 | Message::Activate(node) => post(node, "activate", String::new(), 0.0), |
| 415 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), | 519 | Message::Hover(node) => post(node, "hover", String::new(), 0.0), |
| @@ -808,6 +912,7 @@ fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Messag | |||
| 808 | if enabled { | 912 | if enabled { |
| 809 | entry = entry | 913 | entry = entry |
| 810 | .on_input(move |text| Message::Change(id, text)) | 914 | .on_input(move |text| Message::Change(id, text)) |
| 915 | + .on_paste(move |text| Message::Paste(id, text)) | ||
| 811 | .on_submit(move |_| Message::Activate(id)); | 916 | .on_submit(move |_| Message::Activate(id)); |
| 812 | } | 917 | } |
| 813 | let width = match width_request(n) { | 918 | let width = match width_request(n) { |
| @@ -927,15 +1032,22 @@ pub extern "C" fn cosmic_quit() { | |||
| 927 | #[no_mangle] | 1032 | #[no_mangle] |
| 928 | pub extern "C" fn cosmic_tree_commit() -> c_int { | 1033 | pub extern "C" fn cosmic_tree_commit() -> c_int { |
| 929 | guard(0, || { | 1034 | guard(0, || { |
| 1035 | + let settled = lock(&INBOX).settled; | ||
| 930 | let snapshot = { | 1036 | let snapshot = { |
| 931 | let mut e = lock(&EDITS); | 1037 | let mut e = lock(&EDITS); |
| 932 | - if !e.dirty { | 1038 | + // A pass that only settled events still publishes, so a control |
| 1039 | + // holding typed text over an older commit lets go of it. | ||
| 1040 | + if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) { | ||
| 933 | return 0; | 1041 | return 0; |
| 934 | } | 1042 | } |
| 935 | e.dirty = false; | 1043 | e.dirty = false; |
| 936 | Arc::new(e.tree.clone()) | 1044 | Arc::new(e.tree.clone()) |
| 937 | }; | 1045 | }; |
| 938 | - *lock(&COMMITTED) = snapshot; | 1046 | + { |
| 1047 | + let mut committed = lock(&COMMITTED); | ||
| 1048 | + *committed = snapshot; | ||
| 1049 | + COMMITTED_SETTLED.store(settled, SeqCst); | ||
| 1050 | + } | ||
| 939 | tell_app(Wake::Tree); | 1051 | tell_app(Wake::Tree); |
| 940 | 1 | 1052 | 1 |
| 941 | }) | 1053 | }) |
| @@ -953,6 +1065,7 @@ pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int { | |||
| 953 | }) | 1065 | }) |
| 954 | .unwrap_or_else(|poisoned| poisoned.into_inner()); | 1066 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 955 | inbox.woken = false; | 1067 | inbox.woken = false; |
| 1068 | + inbox.settled = inbox.taken; | ||
| 956 | c_int::from(!inbox.queue.is_empty()) | 1069 | c_int::from(!inbox.queue.is_empty()) |
| 957 | }) | 1070 | }) |
| 958 | } | 1071 | } |
| @@ -1024,6 +1137,29 @@ pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int { | |||
| 1024 | }) | 1137 | }) |
| 1025 | } | 1138 | } |
| 1026 | 1139 | ||
| 1140 | +/// Write the picture the last empty Ctrl+V found on the clipboard to `path`. | ||
| 1141 | +/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was | ||
| 1142 | +/// already taken, or when the file could not be written. | ||
| 1143 | +/// | ||
| 1144 | +/// # Safety | ||
| 1145 | +/// `path` is null or a NUL-terminated string. | ||
| 1146 | +#[no_mangle] | ||
| 1147 | +pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int { | ||
| 1148 | + let path = borrowed(path); | ||
| 1149 | + guard(0, || { | ||
| 1150 | + let Some(png) = lock(&CLIPBOARD_PNG).take() else { | ||
| 1151 | + return 0; | ||
| 1152 | + }; | ||
| 1153 | + match std::fs::write(&*path, png) { | ||
| 1154 | + Ok(()) => 1, | ||
| 1155 | + Err(err) => { | ||
| 1156 | + eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}"); | ||
| 1157 | + 0 | ||
| 1158 | + } | ||
| 1159 | + } | ||
| 1160 | + }) | ||
| 1161 | +} | ||
| 1162 | + | ||
| 1027 | // --- the C ABI: events ----------------------------------------------------------- | 1163 | // --- the C ABI: events ----------------------------------------------------------- |
| 1028 | 1164 | ||
| 1029 | static EVENT_NAME: Scratch = Scratch::new(); | 1165 | static EVENT_NAME: Scratch = Scratch::new(); |
| @@ -1036,6 +1172,9 @@ pub extern "C" fn cosmic_tree_poll_event() -> c_int { | |||
| 1036 | let mut inbox = lock(&INBOX); | 1172 | let mut inbox = lock(&INBOX); |
| 1037 | let next = inbox.queue.pop_front(); | 1173 | let next = inbox.queue.pop_front(); |
| 1038 | let got = next.is_some(); | 1174 | let got = next.is_some(); |
| 1175 | + if let Some(e) = &next { | ||
| 1176 | + inbox.taken = e.seq; | ||
| 1177 | + } | ||
| 1039 | inbox.current = next; | 1178 | inbox.current = next; |
| 1040 | c_int::from(got) | 1179 | c_int::from(got) |
| 1041 | }) | 1180 | }) |
| @@ -1204,6 +1343,28 @@ mod tests { | |||
| 1204 | id | 1343 | id |
| 1205 | } | 1344 | } |
| 1206 | 1345 | ||
| 1346 | + #[test] | ||
| 1347 | + fn typed_text_stands_over_a_commit_that_has_not_seen_it() { | ||
| 1348 | + let mut t = Tree::default(); | ||
| 1349 | + let root = t.root(); | ||
| 1350 | + let entry = node(&mut t, root, "entry"); | ||
| 1351 | + t.set(entry, "text", Prop::Str("a".into())); | ||
| 1352 | + let mut tree = Arc::new(t); | ||
| 1353 | + let mut typed = HashMap::new(); | ||
| 1354 | + typed.insert((entry, "text"), (2, Prop::Str("ab".into()))); | ||
| 1355 | + | ||
| 1356 | + // Rendered before the worker saw the "b". | ||
| 1357 | + keep_typed(&mut tree, &mut typed, 1); | ||
| 1358 | + assert_eq!(tree.get(entry).unwrap().str("text"), "ab"); | ||
| 1359 | + assert_eq!(typed.len(), 1); | ||
| 1360 | + | ||
| 1361 | + // Rendered after: the component cleared its draft, and that stands. | ||
| 1362 | + Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new())); | ||
| 1363 | + keep_typed(&mut tree, &mut typed, 2); | ||
| 1364 | + assert_eq!(tree.get(entry).unwrap().str("text"), ""); | ||
| 1365 | + assert!(typed.is_empty()); | ||
| 1366 | + } | ||
| 1367 | + | ||
| 1207 | #[test] | 1368 | #[test] |
| 1208 | fn a_zero_width_request_is_no_request() { | 1369 | fn a_zero_width_request_is_no_request() { |
| 1209 | let mut t = Tree::default(); | 1370 | let mut t = Tree::default(); |
modified
glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/core.clj +10 -0 | @@ -181,6 +181,9 @@ | ||
| 181 | 181 | ;; the pointer comes onto the widget and once when it leaves. |
| 182 | 182 | "hover" (when-let [f (:on-hover hs)] (f)) |
| 183 | 183 | "unhover" (when-let [f (:on-unhover hs)] (f)) |
| 184 | + ;; Ctrl+V on a clipboard with no text on it. What is on it instead is | |
| 185 | + ;; the caller's to find out, with `clipboard-image-png!`. | |
| 186 | + "paste-empty" (when-let [f (:on-paste-empty hs)] (f)) | |
| 184 | 187 | nil)) |
| 185 | 188 | (recur)))) |
| 186 | 189 | |
| @@ -291,6 +294,13 @@ | ||
| 291 | 294 | [path] |
| 292 | 295 | (ffi/picked-image! path)) |
| 293 | 296 | |
| 297 | +(defn clipboard-image-png! | |
| 298 | + "Write the picture on the clipboard to `path` as PNG. True when there was | |
| 299 | + one — as found by the Ctrl+V that fired the `:entry`'s `:on-paste-empty`, | |
| 300 | + since libcosmic reads its clipboard on its own thread, not on demand." | |
| 301 | + [path] | |
| 302 | + (ffi/clipboard-image-png! path)) | |
| 303 | + | |
| 294 | 304 | (def backend |
| 295 | 305 | {:name :cosmic |
| 296 | 306 | :create! create! |
| @@ -181,6 +181,9 @@ | |||
| 181 | ;; the pointer comes onto the widget and once when it leaves. | 181 | ;; the pointer comes onto the widget and once when it leaves. |
| 182 | "hover" (when-let [f (:on-hover hs)] (f)) | 182 | "hover" (when-let [f (:on-hover hs)] (f)) |
| 183 | "unhover" (when-let [f (:on-unhover hs)] (f)) | 183 | "unhover" (when-let [f (:on-unhover hs)] (f)) |
| 184 | + ;; Ctrl+V on a clipboard with no text on it. What is on it instead is | ||
| 185 | + ;; the caller's to find out, with `clipboard-image-png!`. | ||
| 186 | + "paste-empty" (when-let [f (:on-paste-empty hs)] (f)) | ||
| 184 | nil)) | 187 | nil)) |
| 185 | (recur)))) | 188 | (recur)))) |
| 186 | 189 | ||
| @@ -291,6 +294,13 @@ | |||
| 291 | [path] | 294 | [path] |
| 292 | (ffi/picked-image! path)) | 295 | (ffi/picked-image! path)) |
| 293 | 296 | ||
| 297 | +(defn clipboard-image-png! | ||
| 298 | + "Write the picture on the clipboard to `path` as PNG. True when there was | ||
| 299 | + one — as found by the Ctrl+V that fired the `:entry`'s `:on-paste-empty`, | ||
| 300 | + since libcosmic reads its clipboard on its own thread, not on demand." | ||
| 301 | + [path] | ||
| 302 | + (ffi/clipboard-image-png! path)) | ||
| 303 | + | ||
| 294 | (def backend | 304 | (def backend |
| 295 | {:name :cosmic | 305 | {:name :cosmic |
| 296 | :create! create! | 306 | :create! create! |
modified
glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/ffi.clj +2 -0 | @@ -59,6 +59,7 @@ | ||
| 59 | 59 | (ffi/defcfn window-height "cosmic_window_height" [] :int) |
| 60 | 60 | (ffi/defcfn raw-pick-image "cosmic_pick_image" [] :int) |
| 61 | 61 | (ffi/defcfn raw-picked-image "cosmic_picked_image" [:string] :int) |
| 62 | +(ffi/defcfn raw-clipboard-image-png "cosmic_clipboard_image_png" [:string] :int) | |
| 62 | 63 | |
| 63 | 64 | ;; --- the int/bool seam ------------------------------------------------------- |
| 64 | 65 | (def system-mode 0) |
| @@ -90,3 +91,4 @@ | ||
| 90 | 91 | (defn poll-event! [] (not (zero? (raw-poll-event)))) |
| 91 | 92 | (defn pick-image! [] (not (zero? (raw-pick-image)))) |
| 92 | 93 | (defn picked-image! [path] (not (zero? (raw-picked-image path)))) |
| 94 | +(defn clipboard-image-png! [path] (not (zero? (raw-clipboard-image-png path)))) | |
| @@ -59,6 +59,7 @@ | |||
| 59 | (ffi/defcfn window-height "cosmic_window_height" [] :int) | 59 | (ffi/defcfn window-height "cosmic_window_height" [] :int) |
| 60 | (ffi/defcfn raw-pick-image "cosmic_pick_image" [] :int) | 60 | (ffi/defcfn raw-pick-image "cosmic_pick_image" [] :int) |
| 61 | (ffi/defcfn raw-picked-image "cosmic_picked_image" [:string] :int) | 61 | (ffi/defcfn raw-picked-image "cosmic_picked_image" [:string] :int) |
| 62 | +(ffi/defcfn raw-clipboard-image-png "cosmic_clipboard_image_png" [:string] :int) | ||
| 62 | 63 | ||
| 63 | ;; --- the int/bool seam ------------------------------------------------------- | 64 | ;; --- the int/bool seam ------------------------------------------------------- |
| 64 | (def system-mode 0) | 65 | (def system-mode 0) |
| @@ -90,3 +91,4 @@ | |||
| 90 | (defn poll-event! [] (not (zero? (raw-poll-event)))) | 91 | (defn poll-event! [] (not (zero? (raw-poll-event)))) |
| 91 | (defn pick-image! [] (not (zero? (raw-pick-image)))) | 92 | (defn pick-image! [] (not (zero? (raw-pick-image)))) |
| 92 | (defn picked-image! [path] (not (zero? (raw-picked-image path)))) | 93 | (defn picked-image! [path] (not (zero? (raw-picked-image path)))) |
| 94 | +(defn clipboard-image-png! [path] (not (zero? (raw-clipboard-image-png path)))) | ||