nandi/jolt-nativepublic Fork 0
1ee075a
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-11T04:31:19-07:00 Browse files
1ee075a parent: 71cdc87
modified crates/jolt-cosmic/src/lib.rs +178 -17
@@ -26,7 +26,7 @@ pub use tree::{Node, Prop, Tree};
2626 use std::collections::{HashMap, HashSet, VecDeque};
2727 use std::ffi::{c_char, c_int};
2828 use std::path::PathBuf;
29-use std::sync::atomic::{AtomicBool, AtomicU32, Ordering::SeqCst};
29+use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst};
3030 use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
3131 use std::time::Duration;
3232
@@ -62,6 +62,9 @@ static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
6262
6363 /// What `view` paints: the tree as of the last commit.
6464 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);
6568
6669 fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
6770 let mut e = lock(&EDITS);
@@ -76,6 +79,7 @@ fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
7679 // --- events, towards jolt ----------------------------------------------------
7780
7881 struct Event {
82+ seq: u64,
7983 node: i32,
8084 name: &'static str,
8185 text: String,
@@ -86,23 +90,47 @@ struct Inbox {
8690 queue: VecDeque<Event>,
8791 current: Option<Event>,
8892 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,
89101 }
90102
91103 static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
92104 queue: VecDeque::new(),
93105 current: None,
94106 woken: false,
107+ posted: 0,
108+ taken: 0,
109+ settled: 0,
95110 });
96111 static BELL: Condvar = Condvar::new();
97112
98113 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+ };
105132 BELL.notify_all();
133+ seq
106134 }
107135
108136 // --- wakes, towards iced -----------------------------------------------------
@@ -133,6 +161,32 @@ enum Pick {
133161
134162 static PICK: Mutex<Pick> = Mutex::new(Pick::Idle);
135163
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+
136190 /// Named for emoji rather than left to fallback: the first face with a glyph
137191 /// for a smiley is often a monochrome one, and the pill then shows an outline.
138192 const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji");
@@ -271,6 +325,9 @@ struct App {
271325 core: Core,
272326 tree: Arc<Tree>,
273327 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)>,
274331 }
275332
276333 #[derive(Clone, Debug)]
@@ -280,6 +337,8 @@ enum Message {
280337 Click(i32),
281338 Toggled(i32, bool),
282339 Change(i32, String),
340+ Paste(i32, String),
341+ PastedPicture(i32, Option<Vec<u8>>),
283342 Activate(i32),
284343 Hover(i32),
285344 Unhover(i32),
@@ -288,13 +347,37 @@ enum Message {
288347 Picked(Option<PathBuf>),
289348 }
290349
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+
291370 impl App {
292371 /// A widget does not own its value: the new state goes into the arena and
293372 /// 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) {
296377 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));
298381 }
299382
300383 /// Take the committed tree, and move every scroll area to where it should
@@ -303,7 +386,12 @@ impl App {
303386 /// A snap is relative, so a list snapped to its end stays at its end as
304387 /// rows arrive under it, until the reader scrolls away.
305388 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);
307395 let mut tasks = Vec::new();
308396 let mut live = HashSet::new();
309397 for ask in scroll_asks(&before, &self.tree) {
@@ -378,6 +466,7 @@ impl cosmic::Application for App {
378466 core,
379467 tree: lock(&COMMITTED).clone(),
380468 scrolls: HashMap::new(),
469+ typed: HashMap::new(),
381470 };
382471 // libcosmic's `wayland` feature brings `multi-window` with it, which
383472 // makes a window title a per-window thing.
@@ -404,12 +493,27 @@ impl cosmic::Application for App {
404493 Message::Quit => return cosmic::iced::exit(),
405494 Message::Click(node) => post(node, "click", String::new(), 0.0),
406495 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);
409498 }
410499 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);
413517 }
414518 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
415519 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
808912 if enabled {
809913 entry = entry
810914 .on_input(move |text| Message::Change(id, text))
915+ .on_paste(move |text| Message::Paste(id, text))
811916 .on_submit(move |_| Message::Activate(id));
812917 }
813918 let width = match width_request(n) {
@@ -927,15 +1032,22 @@ pub extern "C" fn cosmic_quit() {
9271032 #[no_mangle]
9281033 pub extern "C" fn cosmic_tree_commit() -> c_int {
9291034 guard(0, || {
1035+ let settled = lock(&INBOX).settled;
9301036 let snapshot = {
9311037 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) {
9331041 return 0;
9341042 }
9351043 e.dirty = false;
9361044 Arc::new(e.tree.clone())
9371045 };
938- *lock(&COMMITTED) = snapshot;
1046+ {
1047+ let mut committed = lock(&COMMITTED);
1048+ *committed = snapshot;
1049+ COMMITTED_SETTLED.store(settled, SeqCst);
1050+ }
9391051 tell_app(Wake::Tree);
9401052 1
9411053 })
@@ -953,6 +1065,7 @@ pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
9531065 })
9541066 .unwrap_or_else(|poisoned| poisoned.into_inner());
9551067 inbox.woken = false;
1068+ inbox.settled = inbox.taken;
9561069 c_int::from(!inbox.queue.is_empty())
9571070 })
9581071 }
@@ -1024,6 +1137,29 @@ pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
10241137 })
10251138 }
10261139
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+
10271163 // --- the C ABI: events -----------------------------------------------------------
10281164
10291165 static EVENT_NAME: Scratch = Scratch::new();
@@ -1036,6 +1172,9 @@ pub extern "C" fn cosmic_tree_poll_event() -> c_int {
10361172 let mut inbox = lock(&INBOX);
10371173 let next = inbox.queue.pop_front();
10381174 let got = next.is_some();
1175+ if let Some(e) = &next {
1176+ inbox.taken = e.seq;
1177+ }
10391178 inbox.current = next;
10401179 c_int::from(got)
10411180 })
@@ -1204,6 +1343,28 @@ mod tests {
12041343 id
12051344 }
12061345
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+
12071368 #[test]
12081369 fn a_zero_width_request_is_no_request() {
12091370 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 glyph190 /// 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 and371 /// 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 a372 /// 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 should383 /// 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 as386 /// 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, which471 // 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 = entry913 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 11052 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 id1343 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 @@
181181 ;; the pointer comes onto the widget and once when it leaves.
182182 "hover" (when-let [f (:on-hover hs)] (f))
183183 "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))
184187 nil))
185188 (recur))))
186189
@@ -291,6 +294,13 @@
291294 [path]
292295 (ffi/picked-image! path))
293296
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+
294304 (def backend
295305 {:name :cosmic
296306 :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 backend304 (def backend
295 {:name :cosmic305 {:name :cosmic
296 :create! create!306 :create! create!
modified glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/ffi.clj +2 -0
@@ -59,6 +59,7 @@
5959 (ffi/defcfn window-height "cosmic_window_height" [] :int)
6060 (ffi/defcfn raw-pick-image "cosmic_pick_image" [] :int)
6161 (ffi/defcfn raw-picked-image "cosmic_picked_image" [:string] :int)
62+(ffi/defcfn raw-clipboard-image-png "cosmic_clipboard_image_png" [:string] :int)
6263
6364 ;; --- the int/bool seam -------------------------------------------------------
6465 (def system-mode 0)
@@ -90,3 +91,4 @@
9091 (defn poll-event! [] (not (zero? (raw-poll-event))))
9192 (defn pick-image! [] (not (zero? (raw-pick-image))))
9293 (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))))