nandi/jolt-nativepublic Fork 0
4706c920e45ce80b11ee106d05c16d9eacc99fc7
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.

lib.rs · 1419 lines · 50.6 KBRust Blame HistoryRaw
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1//! glimmer's libcosmic backend: the retained-tree ABI, with iced reading it.
2//!
3//! The edit half is libvidya's — integer node handles, string-keyed props,
4//! events queued and polled — so `glimmer-cosmic` is `glimmer-vidya` pointed
5//! at a different object. What changes is who owns the loop.
6//!
7//! egui lets its caller drive frames; iced does not. `cosmic::app::run` takes
8//! the main thread (winit insists) and returns when the window closes. So the
9//! arrangement is inverted:
10//!
11//! * `cosmic_run` blocks the process main thread inside libcosmic.
12//! * jolt reconciles on a worker thread, mutating the arena under a mutex.
13//! Nothing it does is visible until `cosmic_tree_commit`, which snapshots the
14//! tree and wakes iced — so a reconcile half-way through a patch is never
15//! painted, and a commit with no edits behind it costs nothing.
16//! * Interactions are queued, and `cosmic_wait` blocks the worker until there
17//! is one (or `cosmic_wake`, or a timeout), so an idle window burns no CPU on
18//! either side.
19//!
20//! Every call except `cosmic_run` may come from any thread.
21
22mod tree;
23
24pub use tree::{Node, Prop, Tree};
25
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago26use std::collections::{HashMap, HashSet, VecDeque};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago27use std::ffi::{c_char, c_int};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago28use std::path::PathBuf;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago29use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago30use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
31use std::time::Duration;
32
33use cosmic::app::{Core, Task};
34use cosmic::iced::futures::channel::mpsc;
35use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago36use cosmic::iced::widget::container::Style as ContainerStyle;
37use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago38use cosmic::iced::widget::text::Wrapping;
39use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago40use cosmic::widget::{self, Column, Row};
41use cosmic::{ApplicationExt, Element};
42use jolt_abi::{borrowed, empty_str, guard, Scratch};
43
44fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
45 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
46}
47
48// --- the arena ---------------------------------------------------------------
49
50struct Edits {
51 tree: Tree,
52 /// Set by every mutation, cleared by a commit that published it.
53 dirty: bool,
54}
55
56static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
57 Mutex::new(Edits {
58 tree: Tree::default(),
59 dirty: false,
60 })
61});
62
63/// What `view` paints: the tree as of the last commit.
64static COMMITTED: LazyLock<Mutex<Arc<Tree>>> = LazyLock::new(Default::default);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago65/// The inbox's `settled` when that commit was made. Written under
66/// `COMMITTED`'s lock, so the two are read as a pair.
67static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago68
69fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
70 let mut e = lock(&EDITS);
71 e.dirty = true;
72 f(&mut e.tree)
73}
74
75fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
76 f(&lock(&EDITS).tree)
77}
78
79// --- events, towards jolt ----------------------------------------------------
80
81struct Event {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago82 seq: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago83 node: i32,
84 name: &'static str,
85 text: String,
86 num: f64,
87}
88
89struct Inbox {
90 queue: VecDeque<Event>,
91 current: Option<Event>,
92 woken: bool,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago93 /// 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,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago101}
102
103static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
104 queue: VecDeque::new(),
105 current: None,
106 woken: false,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago107 posted: 0,
108 taken: 0,
109 settled: 0,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago110});
111static BELL: Condvar = Condvar::new();
112
113fn post(node: i32, name: &'static str, text: String, num: f64) {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago114 post_seq(node, name, text, num);
115}
116
117/// Queue an event for the worker; answers its sequence number.
118fn 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 };
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago132 BELL.notify_all();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago133 seq
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago134}
135
136// --- wakes, towards iced -----------------------------------------------------
137
138enum Wake {
139 Tree,
140 Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago141 PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago142}
143
144static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
145static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
146static RAN: AtomicBool = AtomicBool::new(false);
147static CLOSED: AtomicBool = AtomicBool::new(false);
148
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago149/// The window's size in points, as libcosmic last reported it. A client that
150/// lays columns out by arithmetic — frq sizes its message list against the
151/// people panel beside it — has to be able to ask.
152static WINDOW_W: AtomicU32 = AtomicU32::new(0);
153static WINDOW_H: AtomicU32 = AtomicU32::new(0);
154
155/// Where a picture chooser opened by `cosmic_pick_image` has got to.
156enum Pick {
157 Idle,
158 Open,
159 Chosen(PathBuf),
160}
161
162static PICK: Mutex<Pick> = Mutex::new(Pick::Idle);
163
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago164/// The picture a Ctrl+V found on the clipboard, held until the worker asks for
165/// it with `cosmic_clipboard_image_png`.
166static 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.
170struct ClipboardPng(Vec<u8>);
171
172impl 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
178impl 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
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago190/// Named for emoji rather than left to fallback: the first face with a glyph
191/// for a smiley is often a monochrome one, and the pill then shows an outline.
192const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji");
193
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago194fn tell_app(wake: Wake) {
195 if let Some(tx) = lock(&TO_APP).as_ref() {
196 let _ = tx.unbounded_send(wake);
197 }
198}
199
200/// The subscription's stream. It opens with a `Tree` wake so a commit made
201/// between `init` and the subscription starting is not missed, and repeats a
202/// quit asked for before there was anyone to tell.
203fn wakes() -> impl Stream<Item = Message> {
204 let (tx, rx) = mpsc::unbounded();
205 let _ = tx.unbounded_send(Wake::Tree);
206 if QUIT_ASKED.load(SeqCst) {
207 let _ = tx.unbounded_send(Wake::Quit);
208 }
209 *lock(&TO_APP) = Some(tx);
210 rx.map(|wake| match wake {
211 Wake::Tree => Message::Tree,
212 Wake::Quit => Message::Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago213 Wake::PickImage => Message::PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago214 })
215}
216
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago217// --- scroll areas ------------------------------------------------------------
218
219/// Where a scroll area was left, kept by name rather than on the widget.
220///
221/// iced keeps a scrollable's offset in its widget tree, and a widget that is
222/// unmounted and mounted again starts at the top. glimmer clients unmount
223/// lists all the time — frq's lightbox is a screen, so looking at a picture
224/// takes the backlog away — so the place is remembered here, under the
225/// `scroll-key` the client names the list by, and put back when it returns.
226struct ScrollMemo {
227 /// Whether the reader is at the newest line. A `stick-to-bottom` list
228 /// follows what arrives only while this holds.
229 at_end: bool,
230 offset_y: f32,
231}
232
233/// Two points of slack: a viewport scrolled to its end by a fractional
234/// offset is still at the end.
235const AT_END_SLACK: f32 = 2.0;
236
237fn scroll_name(n: &Node, id: i32) -> String {
238 match n.str("scroll-key") {
239 "" => format!("node-{id}"),
240 key => key.to_owned(),
241 }
242}
243
244fn scroll_id(name: &str) -> widget::Id {
245 widget::Id::new(format!("jolt-scroll-{name}"))
246}
247
248fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
249 if let Some(n) = t.get(id) {
250 f(id, n);
251 for child in &n.children {
252 walk(t, *child, f);
253 }
254 }
255}
256
257/// What a commit asks of one scroll area.
258struct ScrollAsk {
259 name: String,
260 stick: bool,
261 /// The `scroll-to-bottom` counter, and what it was in the tree before.
262 tick: Option<f64>,
263 tick_before: Option<f64>,
264 /// Not in the tree before this commit: mounted, or mounted again.
265 fresh: bool,
266 /// Where, as a fraction of the list, a node that has just been asked to be
267 /// shown sits.
268 reveal: Option<f32>,
269}
270
271/// Every scroll area in `now`, and what changed about each since `before`.
272fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
273 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
274 walk(before, before.root_id(), &mut |id, n| {
275 if n.tag == "scroll" {
276 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
277 }
278 });
279
280 let mut asks = Vec::new();
281 walk(now, now.root_id(), &mut |id, n| {
282 if n.tag != "scroll" {
283 return;
284 }
285 let name = scroll_name(n, id);
286 // A row asking to be shown, that was not asking last commit. The
287 // position is the index of the top-level row holding it: exact for a
288 // list of rows the same height and close for frq's backlog, and there
289 // is no layout to ask from here.
290 let count = n.children.len();
291 let mut reveal = None;
292 for (index, row) in n.children.iter().enumerate() {
293 let mut asked = false;
294 walk(now, *row, &mut |nid, node| {
295 let here = node.bool("scroll-here") == Some(true);
296 let was = before
297 .get(nid)
298 .is_some_and(|old| old.bool("scroll-here") == Some(true));
299 asked |= here && !was;
300 });
301 if asked {
302 reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);
303 break;
304 }
305 }
306 asks.push(ScrollAsk {
307 fresh: !named_before.contains_key(&name),
308 tick_before: named_before.get(&name).copied().flatten(),
309 tick: n.num("scroll-to-bottom"),
310 stick: n.bool("stick-to-bottom") == Some(true),
311 reveal,
312 name,
313 });
314 });
315 asks
316}
317
318fn snap_to_end(name: &str) -> Task<Message> {
319 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
320}
321
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago322// --- the app -----------------------------------------------------------------
323
324struct App {
325 core: Core,
326 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago327 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago328 /// 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)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago331}
332
333#[derive(Clone, Debug)]
334enum Message {
335 Tree,
336 Quit,
337 Click(i32),
338 Toggled(i32, bool),
339 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago340 Paste(i32, String),
341 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago342 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago343 Hover(i32),
344 Unhover(i32),
345 Scrolled(i32, String, Viewport),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago346 PickImage,
347 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago348}
349
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago350/// 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.
357fn 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
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago370impl App {
371 /// A widget does not own its value: the new state goes into the arena and
372 /// into what is painted, so a caller that ignores the event still sees a
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago373 /// 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) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago377 edit(|t| t.set(node, key, value.clone()));
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago378 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));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago381 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago382
383 /// Take the committed tree, and move every scroll area to where it should
384 /// be now that it has changed.
385 ///
386 /// A snap is relative, so a list snapped to its end stays at its end as
387 /// rows arrive under it, until the reader scrolls away.
388 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago389 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);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago395 let mut tasks = Vec::new();
396 let mut live = HashSet::new();
397 for ask in scroll_asks(&before, &self.tree) {
398 live.insert(ask.name.clone());
399 let memo = self
400 .scrolls
401 .entry(ask.name.clone())
402 .or_insert(ScrollMemo {
403 at_end: ask.stick,
404 offset_y: 0.0,
405 });
406 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
407 if let Some(fraction) = ask.reveal {
408 memo.at_end = false;
409 tasks.push(iced_scrollable::snap_to(
410 scroll_id(&ask.name),
411 RelativeOffset { x: None, y: Some(fraction) },
412 ));
413 } else if jumped || (ask.stick && memo.at_end) {
414 memo.at_end = true;
415 tasks.push(snap_to_end(&ask.name));
416 } else if ask.fresh {
417 tasks.push(iced_scrollable::scroll_to(
418 scroll_id(&ask.name),
419 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
420 ));
421 }
422 }
423 // A list that was never scrolled keeps no memo worth the space; one
424 // that was keeps its place for when it comes back.
425 self.scrolls
426 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
427 Task::batch(tasks)
428 }
429
430 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
431 let y = viewport.absolute_offset().y;
432 let room = viewport.content_bounds().height - viewport.bounds().height;
433 let at_end = room - y <= AT_END_SLACK;
434 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
435 at_end,
436 offset_y: y,
437 });
438 let was = memo.at_end;
439 memo.at_end = at_end;
440 memo.offset_y = y;
441 // "end" or "away", the strings libvidya emits: frq's handler compares
442 // against "end".
443 if was != at_end {
444 let place = if at_end { "end" } else { "away" };
445 post(node, "change", place.to_owned(), 0.0);
446 }
447 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago448}
449
450impl cosmic::Application for App {
451 type Executor = cosmic::executor::Default;
452 type Flags = String;
453 type Message = Message;
454 const APP_ID: &'static str = "dev.jolt.Glimmer";
455
456 fn core(&self) -> &Core {
457 &self.core
458 }
459
460 fn core_mut(&mut self) -> &mut Core {
461 &mut self.core
462 }
463
464 fn init(core: Core, title: String) -> (Self, Task<Message>) {
465 let mut app = App {
466 core,
467 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago468 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago469 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago470 };
471 // libcosmic's `wayland` feature brings `multi-window` with it, which
472 // makes a window title a per-window thing.
473 app.set_header_title(title.clone());
474 let task = match app.core.main_window_id() {
475 Some(id) => app.set_window_title(title, id),
476 None => Task::none(),
477 };
478 (app, task)
479 }
480
481 fn subscription(&self) -> Subscription<Message> {
482 Subscription::run(wakes)
483 }
484
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago485 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
486 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
487 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
488 }
489
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago490 fn update(&mut self, message: Message) -> Task<Message> {
491 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago492 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago493 Message::Quit => return cosmic::iced::exit(),
494 Message::Click(node) => post(node, "click", String::new(), 0.0),
495 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago496 let num = f64::from(u8::from(on));
497 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago498 }
499 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago500 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);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago517 }
518 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago519 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
520 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
521 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago522 // The desktop's own chooser, through the portal, on libcosmic's
523 // executor: it is a D-Bus round trip, and the window keeps
524 // painting while it is open.
525 Message::PickImage => {
526 return Task::perform(
527 async {
528 rfd::AsyncFileDialog::new()
529 .set_title("Choose a picture")
530 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
531 .pick_file()
532 .await
533 .map(|file| file.path().to_path_buf())
534 },
535 |path| cosmic::Action::App(Message::Picked(path)),
536 );
537 }
538 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago539 }
540 Task::none()
541 }
542
543 fn view(&self) -> Element<'_, Message> {
544 let tree = &*self.tree;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago545 element(tree, tree.root_id(), true, false)
546 }
547}
548
549// --- props into layout -----------------------------------------------------------
550
551/// `margin` all round, with `margin-top` and its siblings overriding a side.
552fn margins(n: &Node) -> Padding {
553 let all = n.num("margin").unwrap_or(0.0) as f32;
554 let side = |key| n.num(key).map_or(all, |v| v as f32);
555 Padding {
556 top: side("margin-top"),
557 right: side("margin-right"),
558 bottom: side("margin-bottom"),
559 left: side("margin-left"),
560 }
561}
562
563/// A width the client asked for. Zero is the client saying "none": frq writes
564/// `:width-request 0` on its message column whenever the people panel is shut,
565/// and taken literally that is a backlog laid out zero points wide.
566fn width_request(n: &Node) -> Option<f32> {
567 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
568}
569
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago570/// `align`, or `default` where it is not set. A row centres its children on
571/// the cross axis by default — a label beside a button otherwise sits against
572/// the top of the button — and a column starts them at the left.
573fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago574 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago575 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago576 "center" => Alignment::Center,
577 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago578 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago579 }
580}
581
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago582fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
583 cosmic::theme::Container::custom(move |_| ContainerStyle {
584 background: Some(Background::Color(color)),
585 border: Border {
586 radius: radius.into(),
587 ..Border::default()
588 },
589 text_color: Some(Color::WHITE),
590 ..ContainerStyle::default()
591 })
592}
593
594/// A colour for somebody, from their name, so the same person is the same
595/// colour everywhere they appear.
596fn name_colour(name: &str) -> Color {
597 const PALETTE: [(f32, f32, f32); 8] = [
598 (0.83, 0.33, 0.33),
599 (0.85, 0.55, 0.20),
600 (0.62, 0.62, 0.18),
601 (0.30, 0.65, 0.35),
602 (0.20, 0.62, 0.62),
603 (0.30, 0.50, 0.85),
604 (0.55, 0.40, 0.85),
605 (0.80, 0.35, 0.65),
606 ];
607 let hash = name
608 .bytes()
609 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
610 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
611 Color::from_rgb(r, g, b)
612}
613
614/// A picture that answers a click, with the pointer saying so.
615fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
616 if !enabled {
617 return el;
618 }
619 widget::mouse_area(el)
620 .on_press(Message::Click(id))
621 .interaction(cosmic::iced::mouse::Interaction::Pointer)
622 .into()
623}
624
625fn picture(path: &str) -> Option<widget::image::Handle> {
626 (!path.is_empty() && std::path::Path::new(path).exists())
627 .then(|| widget::image::Handle::from_path(path))
628}
629
630// --- the tree into widgets ---------------------------------------------------------
631
632/// One node and everything under it, as widgets.
633///
634/// `enabled` is inherited: an insensitive container takes its whole subtree out
635/// of interaction. `in_row` is whether the parent lays its children out across:
636/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
637/// column in a column takes the width and a column in a row does not take the
638/// row's slack unless it says `fill-height`.
639fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago640 let Some(n) = t.get(id) else {
641 return Column::new().into();
642 };
643 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago644 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago645 // glimmer-jvui's theme spacing, where the client does not say: a list of
646 // cards with nothing between them reads as one slab.
647 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago648 let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago649
650 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago651 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago652 .width(Length::Fill)
653 .height(Length::Fill)
654 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago655 // Sizes are set only where something asked for one. iced's rows and
656 // columns take `Fill` on an axis from any child that fills it, which is
657 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
658 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
659 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago660 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago661 let across = n.str("orientation") == "horizontal";
662 if across {
663 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago664 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago665 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago666 .align_y(alignment(n, Alignment::Center));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago667 // A row fills the width it is in only when it or something in
668 // it asks to; otherwise a line of buttons would spread out.
669 match width_request(n) {
670 Some(w) => row = row.width(w),
671 None if fill_height => row = row.width(Length::Fill),
672 None => {}
673 }
674 if fill_height {
675 row = row.height(Length::Fill);
676 }
677 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago678 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago679 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago680 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago681 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago682 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago683 match width_request(n) {
684 Some(w) => column = column.width(w),
685 None if fill_height || !in_row => column = column.width(Length::Fill),
686 None => {}
687 }
688 if fill_height {
689 column = column.height(Length::Fill);
690 }
691 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago692 }
693 }
694 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago695 let column = Column::with_children(children(false))
696 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago697 .padding(24)
698 .width(Length::Fill);
699 let mut inner = widget::container(column).width(Length::Fill);
700 if let Some(max) = n.num("max-width") {
701 inner = inner.max_width(max as f32);
702 }
703 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago704 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago705 .height(Length::Fill)
706 .into()
707 }
708 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago709 let mut column = Column::new().spacing(n.num("spacing").unwrap_or(8.0) as f32);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago710 if n.tag == "frame" && !n.label().is_empty() {
711 column = column.push(widget::text::heading(n.label()));
712 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago713 let card = widget::container(column.extend(children(false)))
714 .padding(12)
715 .class(cosmic::theme::Container::Card);
716 match width_request(n) {
717 Some(w) => card.width(w).into(),
718 None if !in_row => card.width(Length::Fill).into(),
719 None => card.into(),
720 }
721 }
722 // Always fills both ways: a viewport that only fills its width asks its
723 // column for no height, and is given none. The content is held to its
724 // own height, since iced will not scroll content that fills the axis it
725 // scrolls along.
726 "scroll" => {
727 let name = scroll_name(n, id);
728 let content = Column::with_children(children(false))
729 .spacing(spacing)
730 .width(Length::Fill)
731 .height(Length::Shrink);
732 widget::scrollable(content)
733 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago734 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago735 .height(Length::Fill)
736 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago737 .into()
738 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago739 // Word wrapping that falls back to breaking inside a word: a URL is one
740 // word, and it otherwise runs straight past the edge of its column.
741 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
742 .wrapping(Wrapping::WordOrGlyph)
743 .into(),
744 "label" => widget::text::body(n.label())
745 .wrapping(Wrapping::WordOrGlyph)
746 .into(),
747 "title" => widget::text::title3(n.label())
748 .wrapping(Wrapping::WordOrGlyph)
749 .into(),
750 "title-2" => widget::text::title4(n.label())
751 .wrapping(Wrapping::WordOrGlyph)
752 .into(),
753 "dim-label" => widget::text::caption(n.label())
754 .wrapping(Wrapping::WordOrGlyph)
755 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago756 "button" => {
757 let button = match n.str("kind") {
758 "primary" => widget::button::suggested(n.label()),
759 "destructive" => widget::button::destructive(n.label()),
760 _ => widget::button::standard(n.label()),
761 };
762 button
763 .on_press_maybe(enabled.then_some(Message::Click(id)))
764 .into()
765 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago766 "link" => widget::button::link(n.label().to_owned())
767 .on_press_maybe(enabled.then_some(Message::Click(id)))
768 .into(),
769 // A dot that says whether the thing is live, and the words beside it.
770 "status" => {
771 let colour = if n.bool("live") == Some(true) {
772 Color::from_rgb(0.30, 0.72, 0.40)
773 } else {
774 Color::from_rgb(0.55, 0.55, 0.55)
775 };
776 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
777 Row::new()
778 .spacing(6)
779 .align_y(Alignment::Center)
780 .push(dot)
781 .push(widget::text::caption(n.label()))
782 .into()
783 }
784 "spinner" => {
785 let mut row = Row::new()
786 .spacing(8)
787 .align_y(Alignment::Center)
788 .push(widget::progress_bar::indeterminate_circular().size(16.0));
789 if !n.label().is_empty() {
790 row = row.push(widget::text::caption(n.label()));
791 }
792 row.into()
793 }
794 "emoji" => {
795 let glyph = match n.str("emoji") {
796 "" => n.label(),
797 e => e,
798 };
799 widget::text(glyph.to_owned())
800 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago801 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago802 .into()
803 }
804 // A round picture, or the initial on a colour from the name: most
805 // people in most rooms have no picture, so the initial IS the avatar.
806 "avatar" => {
807 let size = n.num("size").unwrap_or(32.0) as f32;
808 match picture(n.str("src")) {
809 Some(handle) => widget::image(handle)
810 .width(size)
811 .height(size)
812 .content_fit(ContentFit::Cover)
813 .border_radius(size / 2.0)
814 .into(),
815 None => {
816 let initial: String = n
817 .label()
818 .trim_start_matches(|c: char| !c.is_alphanumeric())
819 .chars()
820 .next()
821 .map(|c| c.to_uppercase().collect())
822 .unwrap_or_default();
823 widget::container(widget::text(initial).size(size * 0.45))
824 .center(Length::Fixed(size))
825 .class(filled(name_colour(n.label()), size / 2.0))
826 .into()
827 }
828 }
829 }
830 // A pill: an emoji, how many people, and whether you are one of them.
831 // What the client hangs under it is its hover card, shown while the
832 // pointer is on the pill.
833 "reaction" => {
834 let glyph = match n.str("emoji") {
835 "" => n.label(),
836 e => e,
837 };
838 let size = n.num("size").unwrap_or(16.0) as f32;
839 let mut content = Row::new()
840 .spacing(4)
841 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago842 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago843 let count = n.num("count").unwrap_or(0.0);
844 if count > 0.0 {
845 content = content.push(widget::text::caption(format!("{count}")));
846 }
847 let class = if n.bool("mine") == Some(true) {
848 widget::button::ButtonClass::Suggested
849 } else {
850 widget::button::ButtonClass::Standard
851 };
852 let pill = widget::button::custom(content)
853 .padding([2, 8])
854 .class(class)
855 .on_press_maybe(enabled.then_some(Message::Click(id)));
856 let pill = widget::mouse_area(pill)
857 .on_enter(Message::Hover(id))
858 .on_exit(Message::Unhover(id));
859 if n.children.is_empty() {
860 pill.into()
861 } else {
862 widget::tooltip(
863 pill,
864 Column::with_children(children(false)).spacing(4),
865 widget::tooltip::Position::Bottom,
866 )
867 .into()
868 }
869 }
870 // One tag for both kinds of picture, as in libvidya. `feed` is live
871 // pixels pushed under a name, which nothing pushes here yet, so it
872 // holds the slot the layout gave it.
873 "image" => {
874 let max_w = n.num("max-width").map(|v| v as f32);
875 let max_h = n.num("max-height").map(|v| v as f32);
876 if !n.str("feed").is_empty() {
877 let w = max_w.unwrap_or(160.0);
878 let h = max_h.unwrap_or(w * 0.75);
879 widget::container(widget::text::caption("video"))
880 .center_x(Length::Fixed(w))
881 .center_y(Length::Fixed(h))
882 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
883 .into()
884 } else if let Some(handle) = picture(n.str("src")) {
885 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
886 if n.bool("fit") == Some(true) {
887 image = image.width(Length::Fill).height(Length::Fill);
888 } else if let Some(size) = n.num("size") {
889 image = image.width(size as f32).height(size as f32);
890 }
891 let mut bounded = widget::container(image);
892 if let Some(w) = max_w {
893 bounded = bounded.max_width(w);
894 }
895 if let Some(h) = max_h {
896 bounded = bounded.max_height(h);
897 }
898 clickable(bounded.into(), id, enabled)
899 } else {
900 widget::Space::new().width(0).height(0).into()
901 }
902 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago903 "checkbutton" => {
904 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
905 if enabled {
906 check = check.on_toggle(move |on| Message::Toggled(id, on));
907 }
908 check.into()
909 }
910 "entry" => {
911 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
912 if enabled {
913 entry = entry
914 .on_input(move |text| Message::Change(id, text))
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago915 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago916 .on_submit(move |_| Message::Activate(id));
917 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago918 let width = match width_request(n) {
919 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
920 _ => Length::Fill,
921 };
922 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago923 }
924 "separator" => widget::divider::horizontal::default().into(),
925 "spacer" => {
926 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago927 if n.str("expand").is_empty() {
928 widget::Space::new().width(size).height(size).into()
929 } else {
930 widget::Space::new().width(Length::Fill).height(size).into()
931 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago932 }
933 "progress" => {
934 let bar =
935 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
936 if n.label().is_empty() {
937 bar.into()
938 } else {
939 Column::new()
940 .spacing(4)
941 .push(widget::text::caption(n.label()))
942 .push(bar)
943 .into()
944 }
945 }
946 // Kept rather than refused, as in libvidya: a tag this backend has not
947 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago948 _ => Column::with_children(children(false))
949 .spacing(spacing)
950 .padding(margins(n))
951 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago952 };
953
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago954 // The containers and the entry size themselves above; anything else asked
955 // for a width gets it from a wrapper.
956 match (n.tag.as_str(), width_request(n)) {
957 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
958 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago959 }
960}
961
962// --- the C ABI: the loop -------------------------------------------------------
963
964static TITLE: Mutex<String> = Mutex::new(String::new());
965
966/// The window's title, read when `cosmic_run` opens it. A call of its own
967/// because jolt will not pass a string to a `:blocking` foreign procedure, and
968/// `cosmic_run` has to be one.
969///
970/// # Safety
971/// `title` is null or a NUL-terminated string.
972#[no_mangle]
973pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
974 let title = borrowed(title);
975 guard((), || *lock(&TITLE) = title)
976}
977
978/// Open the window and run libcosmic until it closes. Blocks; call it on the
979/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
980///
981/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
982/// in this process — winit's event loop cannot be made twice.
983#[no_mangle]
984pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
985 let status = guard(1, || {
986 let title = lock(&TITLE).clone();
987 if RAN.swap(true, SeqCst) {
988 log::error!("jolt-cosmic: a window already ran in this process");
989 return 2;
990 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago991 // The size asked for, until libcosmic reports the one it got.
992 WINDOW_W.store(width.max(1) as u32, SeqCst);
993 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago994 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
995 let mut settings = cosmic::app::Settings::default().size(size);
996 match mode {
997 1 => settings = settings.theme(cosmic::Theme::dark()),
998 2 => settings = settings.theme(cosmic::Theme::light()),
999 _ => {}
1000 }
1001 match cosmic::app::run::<App>(settings, title) {
1002 Ok(()) => 0,
1003 Err(err) => {
1004 eprintln!("jolt-cosmic: {err}");
1005 1
1006 }
1007 }
1008 });
1009 // Outside the guard, so a panic in libcosmic still releases the worker.
1010 *lock(&TO_APP) = None;
1011 CLOSED.store(true, SeqCst);
1012 BELL.notify_all();
1013 status
1014}
1015
1016/// 1 once `cosmic_run` has returned.
1017#[no_mangle]
1018pub extern "C" fn cosmic_should_close() -> c_int {
1019 c_int::from(CLOSED.load(SeqCst))
1020}
1021
1022/// Close the window. Asked before the window exists, it closes on opening.
1023#[no_mangle]
1024pub extern "C" fn cosmic_quit() {
1025 guard((), || {
1026 QUIT_ASKED.store(true, SeqCst);
1027 tell_app(Wake::Quit);
1028 })
1029}
1030
1031/// Publish the edits since the last commit. Answers 1 when there were any.
1032#[no_mangle]
1033pub extern "C" fn cosmic_tree_commit() -> c_int {
1034 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1035 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1036 let snapshot = {
1037 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1038 // 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) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1041 return 0;
1042 }
1043 e.dirty = false;
1044 Arc::new(e.tree.clone())
1045 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1046 {
1047 let mut committed = lock(&COMMITTED);
1048 *committed = snapshot;
1049 COMMITTED_SETTLED.store(settled, SeqCst);
1050 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1051 tell_app(Wake::Tree);
1052 1
1053 })
1054}
1055
1056/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1057/// closing. Answers 1 when an event is waiting.
1058#[no_mangle]
1059pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1060 guard(0, || {
1061 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1062 let (mut inbox, _) = BELL
1063 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1064 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1065 })
1066 .unwrap_or_else(|poisoned| poisoned.into_inner());
1067 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1068 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1069 c_int::from(!inbox.queue.is_empty())
1070 })
1071}
1072
1073/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1074#[no_mangle]
1075pub extern "C" fn cosmic_wake() {
1076 guard((), || {
1077 lock(&INBOX).woken = true;
1078 BELL.notify_all();
1079 })
1080}
1081
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1082// --- the C ABI: the window and the desktop -----------------------------------------
1083
1084/// The window's width in points; the size asked for until it has opened.
1085#[no_mangle]
1086pub extern "C" fn cosmic_window_width() -> c_int {
1087 WINDOW_W.load(SeqCst) as c_int
1088}
1089
1090#[no_mangle]
1091pub extern "C" fn cosmic_window_height() -> c_int {
1092 WINDOW_H.load(SeqCst) as c_int
1093}
1094
1095/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1096/// there is no window to ask from; the choice arrives through
1097/// `cosmic_picked_image`.
1098#[no_mangle]
1099pub extern "C" fn cosmic_pick_image() -> c_int {
1100 guard(0, || {
1101 if lock(&TO_APP).is_none() {
1102 return 0;
1103 }
1104 *lock(&PICK) = Pick::Open;
1105 tell_app(Wake::PickImage);
1106 1
1107 })
1108}
1109
1110/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1111/// was chosen since the last call; 0 while the chooser is open, after it was
1112/// cancelled, or when the picture could not be read.
1113///
1114/// # Safety
1115/// `path` is null or a NUL-terminated string.
1116#[no_mangle]
1117pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1118 let path = borrowed(path);
1119 guard(0, || {
1120 let chosen = {
1121 let mut pick = lock(&PICK);
1122 match std::mem::replace(&mut *pick, Pick::Idle) {
1123 Pick::Chosen(chosen) => chosen,
1124 other => {
1125 *pick = other;
1126 return 0;
1127 }
1128 }
1129 };
1130 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1131 Ok(()) => 1,
1132 Err(err) => {
1133 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1134 0
1135 }
1136 }
1137 })
1138}
1139
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1140/// 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]
1147pub 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
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1163// --- the C ABI: events -----------------------------------------------------------
1164
1165static EVENT_NAME: Scratch = Scratch::new();
1166static EVENT_TEXT: Scratch = Scratch::new();
1167
1168/// Dequeue one event; 1 while there was one. The accessors describe it.
1169#[no_mangle]
1170pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1171 guard(0, || {
1172 let mut inbox = lock(&INBOX);
1173 let next = inbox.queue.pop_front();
1174 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1175 if let Some(e) = &next {
1176 inbox.taken = e.seq;
1177 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1178 inbox.current = next;
1179 c_int::from(got)
1180 })
1181}
1182
1183#[no_mangle]
1184pub extern "C" fn cosmic_tree_event_node() -> c_int {
1185 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1186}
1187
1188#[no_mangle]
1189pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1190 guard(empty_str(), || {
1191 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1192 })
1193}
1194
1195#[no_mangle]
1196pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1197 guard(empty_str(), || {
1198 let text = lock(&INBOX)
1199 .current
1200 .as_ref()
1201 .map(|e| e.text.clone())
1202 .unwrap_or_default();
1203 EVENT_TEXT.lend(text)
1204 })
1205}
1206
1207#[no_mangle]
1208pub extern "C" fn cosmic_tree_event_num() -> f64 {
1209 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1210}
1211
1212// --- the C ABI: nodes --------------------------------------------------------------
1213
1214static PROPS: Scratch = Scratch::new();
1215static DUMP: Scratch = Scratch::new();
1216
1217#[no_mangle]
1218pub extern "C" fn cosmic_tree_root() -> c_int {
1219 guard(0, || edit(Tree::root))
1220}
1221
1222/// # Safety
1223/// `tag` is null or a NUL-terminated string.
1224#[no_mangle]
1225pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1226 let tag = borrowed(tag);
1227 guard(0, || edit(|t| t.new_node(&tag)))
1228}
1229
1230#[no_mangle]
1231pub extern "C" fn cosmic_node_free(node: c_int) {
1232 guard((), || edit(|t| t.free(node)))
1233}
1234
1235#[no_mangle]
1236pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1237 guard(0, || c_int::from(read(|t| t.exists(node))))
1238}
1239
1240/// # Safety
1241/// `key` and `value` are null or NUL-terminated strings.
1242#[no_mangle]
1243pub unsafe extern "C" fn cosmic_node_set_str(
1244 node: c_int,
1245 key: *const c_char,
1246 value: *const c_char,
1247) {
1248 let (key, value) = (borrowed(key), borrowed(value));
1249 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1250}
1251
1252/// # Safety
1253/// `key` is null or a NUL-terminated string.
1254#[no_mangle]
1255pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1256 let key = borrowed(key);
1257 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1258}
1259
1260/// # Safety
1261/// `key` is null or a NUL-terminated string.
1262#[no_mangle]
1263pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1264 let key = borrowed(key);
1265 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1266}
1267
1268#[no_mangle]
1269pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1270 guard((), || edit(|t| t.clear_props(node)))
1271}
1272
1273#[no_mangle]
1274pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1275 guard(empty_str(), || {
1276 PROPS.lend(read(|t| {
1277 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1278 }))
1279 })
1280}
1281
1282#[no_mangle]
1283pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1284 guard(0, || {
1285 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1286 })
1287}
1288
1289#[no_mangle]
1290pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1291 guard(0, || {
1292 read(|t| {
1293 t.get(node)
1294 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1295 .unwrap_or(0)
1296 })
1297 })
1298}
1299
1300#[no_mangle]
1301pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1302 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1303}
1304
1305/// Unparents AND frees `child` with everything under it.
1306#[no_mangle]
1307pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1308 guard((), || edit(|t| t.remove(parent, child)))
1309}
1310
1311#[no_mangle]
1312pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1313 guard(0, || {
1314 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1315 })
1316}
1317
1318#[no_mangle]
1319pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1320 guard(0, || {
1321 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1322 })
1323}
1324
1325/// The subtree at `node` as hiccup; 0 is the root.
1326#[no_mangle]
1327pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1328 guard(empty_str(), || {
1329 DUMP.lend(read(|t| {
1330 let id = if node == 0 { t.root_id() } else { node };
1331 t.dump(id)
1332 }))
1333 })
1334}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1335
1336#[cfg(test)]
1337mod tests {
1338 use super::*;
1339
1340 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1341 let id = t.new_node(tag);
1342 assert!(t.append(parent, id));
1343 id
1344 }
1345
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1346 #[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
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1368 #[test]
1369 fn a_zero_width_request_is_no_request() {
1370 let mut t = Tree::default();
1371 let root = t.root();
1372 let column = node(&mut t, root, "vbox");
1373 t.set(column, "width-request", Prop::Num(0.0));
1374 assert_eq!(width_request(t.get(column).unwrap()), None);
1375 t.set(column, "width-request", Prop::Num(260.0));
1376 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1377 }
1378
1379 #[test]
1380 fn a_scroll_is_named_by_its_scroll_key() {
1381 let mut t = Tree::default();
1382 let root = t.root();
1383 let list = node(&mut t, root, "scroll");
1384 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1385 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1386 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1387 }
1388
1389 #[test]
1390 fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {
1391 let mut t = Tree::default();
1392 let root = t.root();
1393 let list = node(&mut t, root, "scroll");
1394 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1395 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1396 let before = t.clone();
1397 t.set(rows[3], "scroll-here", Prop::Bool(true));
1398
1399 let asks = scroll_asks(&before, &t);
1400 assert_eq!(asks.len(), 1);
1401 assert!(!asks[0].fresh);
1402 assert_eq!(asks[0].reveal, Some(0.75));
1403
1404 let again = scroll_asks(&t, &t);
1405 assert_eq!(again[0].reveal, None);
1406 }
1407
1408 #[test]
1409 fn a_jump_is_the_counter_moving() {
1410 let mut t = Tree::default();
1411 let root = t.root();
1412 let list = node(&mut t, root, "scroll");
1413 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1414 let before = t.clone();
1415 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1416 let asks = scroll_asks(&before, &t);
1417 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1418 }
1419}