nandi/jolt-nativepublic Fork 0
228672deff39ee418d1b734ed0920ce16985e8e1
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 · 1553 lines · 57.1 KBRust Blame HistoryRaw
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d ago539 }
540 Task::none()
541 }
542
543 fn view(&self) -> Element<'_, Message> {
544 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago545 let root = element(tree, tree.root_id(), true, false);
546 // A dialog that asked not to be modal, put up here rather than handed
547 // to `dialog` below. It is the same widget in the same place — a
548 // `popover` centres it exactly as `cosmic::app` does — and the whole
549 // of the difference is that this one is not told to intercept the
550 // pointer. That matters to anything the pointer opened: a modal
551 // popover hands the window underneath it a cursor that is
552 // `Unavailable`, so a face that opened a dialog on hover never hears
553 // the pointer leave, and what it opened can never close itself.
554 //
555 // The popover is here whether or not there is anything in it, which
556 // `cosmic::app` says of its own in one line and which this learned
557 // the long way: iced keeps a widget's state by where it sits in the
558 // tree, so a wrapper that comes and goes rebuilds everything under
559 // it — and what "everything" holds is the scroll positions. Wrapping
560 // only when a dialog appeared meant resting the pointer on a face
561 // jumped the conversation behind it.
562 let mut popover = widget::popover(root);
563 if let Some(id) = find_dialog(tree, false) {
564 // The dialog reports its own pointer, on the same two events a
565 // face or a pill reports theirs. Without it a dialog the pointer
566 // opened can only be read at arm's length: the client is told the
567 // pointer left what opened it and never told it arrived here, so
568 // the one way to keep it up is not to move — and everything in it
569 // is out of reach.
570 let popup = widget::mouse_area(dialog_of(tree, id))
571 .on_enter(Message::Hover(id))
572 .on_exit(Message::Unhover(id));
573 popover = popover.popup(popup);
574 }
575 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago576 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago577
578 /// The MODAL dialog the tree is carrying, if it is carrying one.
579 ///
580 /// A client says there is one by putting a `dialog` node in the tree and
581 /// says there is not by leaving it out — the same way it says anything
582 /// else. What comes back is libcosmic's own dialog: centred, over a
583 /// dimmed window, and closed by the buttons the client hung on it.
584 ///
585 /// A dialog that says `modal false` does not come back here. This hook is
586 /// the modal one whether the client wants it or not — `cosmic::app` wraps
587 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
588 /// that kind up itself. See `dialog_of`.
589 fn dialog(&self) -> Option<Element<'_, Message>> {
590 let tree = &*self.tree;
591 let id = find_dialog(tree, true)?;
592 Some(dialog_of(tree, id))
593 }
594}
595
596/// The first `dialog` node in the tree whose modality is `modal`.
597///
598/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
599/// not, which is the shape everything else here takes — a prop left out is
600/// the ordinary answer.
601fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
602 let mut found = None;
603 walk(t, t.root_id(), &mut |id, n| {
604 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
605 found = Some(id);
606 }
607 });
608 found
609}
610
611/// One `dialog` node as libcosmic's dialog.
612///
613/// `label` is its heading and `body` the line under it. Children are its
614/// controls, in order, except that a child carrying `slot` "primary" or
615/// "secondary" becomes that action instead — which is where libcosmic puts
616/// the buttons, at the foot and to the right.
617fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
618 let Some(n) = t.get(id) else {
619 return widget::Space::new().width(0).height(0).into();
620 };
621 let mut d = widget::dialog();
622 if !n.label().is_empty() {
623 d = d.title(n.label().to_owned());
624 }
625 if !n.str("body").is_empty() {
626 d = d.body(n.str("body").to_owned());
627 }
628 if let Some(w) = n.num("max-width") {
629 d = d.max_width(w as f32);
630 }
631 for child in &n.children {
632 let Some(c) = t.get(*child) else { continue };
633 let el = element(t, *child, true, false);
634 d = match c.str("slot") {
635 "primary" => d.primary_action(el),
636 "secondary" => d.secondary_action(el),
637 _ => d.control(el),
638 };
639 }
640 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago641}
642
643// --- props into layout -----------------------------------------------------------
644
645/// `margin` all round, with `margin-top` and its siblings overriding a side.
646fn margins(n: &Node) -> Padding {
647 let all = n.num("margin").unwrap_or(0.0) as f32;
648 let side = |key| n.num(key).map_or(all, |v| v as f32);
649 Padding {
650 top: side("margin-top"),
651 right: side("margin-right"),
652 bottom: side("margin-bottom"),
653 left: side("margin-left"),
654 }
655}
656
657/// A width the client asked for. Zero is the client saying "none": frq writes
658/// `:width-request 0` on its message column whenever the people panel is shut,
659/// and taken literally that is a backlog laid out zero points wide.
660fn width_request(n: &Node) -> Option<f32> {
661 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
662}
663
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago664/// `align`, or `default` where it is not set. A row centres its children on
665/// the cross axis by default — a label beside a button otherwise sits against
666/// the top of the button — and a column starts them at the left.
667fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago668 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago669 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago670 "center" => Alignment::Center,
671 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago672 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago673 }
674}
675
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago676fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
677 cosmic::theme::Container::custom(move |_| ContainerStyle {
678 background: Some(Background::Color(color)),
679 border: Border {
680 radius: radius.into(),
681 ..Border::default()
682 },
683 text_color: Some(Color::WHITE),
684 ..ContainerStyle::default()
685 })
686}
687
688/// A colour for somebody, from their name, so the same person is the same
689/// colour everywhere they appear.
690fn name_colour(name: &str) -> Color {
691 const PALETTE: [(f32, f32, f32); 8] = [
692 (0.83, 0.33, 0.33),
693 (0.85, 0.55, 0.20),
694 (0.62, 0.62, 0.18),
695 (0.30, 0.65, 0.35),
696 (0.20, 0.62, 0.62),
697 (0.30, 0.50, 0.85),
698 (0.55, 0.40, 0.85),
699 (0.80, 0.35, 0.65),
700 ];
701 let hash = name
702 .bytes()
703 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
704 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
705 Color::from_rgb(r, g, b)
706}
707
708/// A picture that answers a click, with the pointer saying so.
709fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
710 if !enabled {
711 return el;
712 }
713 widget::mouse_area(el)
714 .on_press(Message::Click(id))
715 .interaction(cosmic::iced::mouse::Interaction::Pointer)
716 .into()
717}
718
719fn picture(path: &str) -> Option<widget::image::Handle> {
720 (!path.is_empty() && std::path::Path::new(path).exists())
721 .then(|| widget::image::Handle::from_path(path))
722}
723
724// --- the tree into widgets ---------------------------------------------------------
725
726/// One node and everything under it, as widgets.
727///
728/// `enabled` is inherited: an insensitive container takes its whole subtree out
729/// of interaction. `in_row` is whether the parent lays its children out across:
730/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
731/// column in a column takes the width and a column in a row does not take the
732/// row's slack unless it says `fill-height`.
733fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago734 let Some(n) = t.get(id) else {
735 return Column::new().into();
736 };
737 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago738 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago739 // glimmer-jvui's theme spacing, where the client does not say: a list of
740 // cards with nothing between them reads as one slab.
741 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago742 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 9d ago743
744 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago745 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago746 .width(Length::Fill)
747 .height(Length::Fill)
748 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago749 // Sizes are set only where something asked for one. iced's rows and
750 // columns take `Fill` on an axis from any child that fills it, which is
751 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
752 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
753 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago754 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago755 let across = n.str("orientation") == "horizontal";
756 if across {
757 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago758 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago759 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago760 .align_y(alignment(n, Alignment::Center));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago761 // A row fills the width it is in only when it or something in
762 // it asks to; otherwise a line of buttons would spread out.
763 match width_request(n) {
764 Some(w) => row = row.width(w),
765 None if fill_height => row = row.width(Length::Fill),
766 None => {}
767 }
768 if fill_height {
769 row = row.height(Length::Fill);
770 }
771 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago772 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago773 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago774 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago775 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago776 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago777 match width_request(n) {
778 Some(w) => column = column.width(w),
779 None if fill_height || !in_row => column = column.width(Length::Fill),
780 None => {}
781 }
782 if fill_height {
783 column = column.height(Length::Fill);
784 }
785 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago786 }
787 }
788 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago789 let column = Column::with_children(children(false))
790 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago791 .padding(24)
792 .width(Length::Fill);
793 let mut inner = widget::container(column).width(Length::Fill);
794 if let Some(max) = n.num("max-width") {
795 inner = inner.max_width(max as f32);
796 }
797 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago798 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago799 .height(Length::Fill)
800 .into()
801 }
802 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago803 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 9d ago804 if n.tag == "frame" && !n.label().is_empty() {
805 column = column.push(widget::text::heading(n.label()));
806 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago807 let card = widget::container(column.extend(children(false)))
808 .padding(12)
809 .class(cosmic::theme::Container::Card);
810 match width_request(n) {
811 Some(w) => card.width(w).into(),
812 None if !in_row => card.width(Length::Fill).into(),
813 None => card.into(),
814 }
815 }
816 // Always fills both ways: a viewport that only fills its width asks its
817 // column for no height, and is given none. The content is held to its
818 // own height, since iced will not scroll content that fills the axis it
819 // scrolls along.
820 "scroll" => {
821 let name = scroll_name(n, id);
822 let content = Column::with_children(children(false))
823 .spacing(spacing)
824 .width(Length::Fill)
825 .height(Length::Shrink);
826 widget::scrollable(content)
827 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago828 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago829 .height(Length::Fill)
830 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago831 .into()
832 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago833 // Word wrapping that falls back to breaking inside a word: a URL is one
834 // word, and it otherwise runs straight past the edge of its column.
835 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
836 .wrapping(Wrapping::WordOrGlyph)
837 .into(),
838 "label" => widget::text::body(n.label())
839 .wrapping(Wrapping::WordOrGlyph)
840 .into(),
841 "title" => widget::text::title3(n.label())
842 .wrapping(Wrapping::WordOrGlyph)
843 .into(),
844 "title-2" => widget::text::title4(n.label())
845 .wrapping(Wrapping::WordOrGlyph)
846 .into(),
847 "dim-label" => widget::text::caption(n.label())
848 .wrapping(Wrapping::WordOrGlyph)
849 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago850 "button" => {
851 let button = match n.str("kind") {
852 "primary" => widget::button::suggested(n.label()),
853 "destructive" => widget::button::destructive(n.label()),
854 _ => widget::button::standard(n.label()),
855 };
856 button
857 .on_press_maybe(enabled.then_some(Message::Click(id)))
858 .into()
859 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago860 "link" => widget::button::link(n.label().to_owned())
861 .on_press_maybe(enabled.then_some(Message::Click(id)))
862 .into(),
863 // A dot that says whether the thing is live, and the words beside it.
864 "status" => {
865 let colour = if n.bool("live") == Some(true) {
866 Color::from_rgb(0.30, 0.72, 0.40)
867 } else {
868 Color::from_rgb(0.55, 0.55, 0.55)
869 };
870 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
871 Row::new()
872 .spacing(6)
873 .align_y(Alignment::Center)
874 .push(dot)
875 .push(widget::text::caption(n.label()))
876 .into()
877 }
878 "spinner" => {
879 let mut row = Row::new()
880 .spacing(8)
881 .align_y(Alignment::Center)
882 .push(widget::progress_bar::indeterminate_circular().size(16.0));
883 if !n.label().is_empty() {
884 row = row.push(widget::text::caption(n.label()));
885 }
886 row.into()
887 }
888 "emoji" => {
889 let glyph = match n.str("emoji") {
890 "" => n.label(),
891 e => e,
892 };
893 widget::text(glyph.to_owned())
894 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago895 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago896 .into()
897 }
898 // A round picture, or the initial on a colour from the name: most
899 // people in most rooms have no picture, so the initial IS the avatar.
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago900 //
901 // And the three things a face is for besides being looked at. It
902 // painted as a picture and nothing else until now: a client that
903 // asked a face to answer a click, to report the pointer arriving, or
904 // to carry a card under it was handed a portrait that did none of
905 // them — so the profile behind every avatar in the window was
906 // unreachable, and the hover card written for it never appeared.
907 // Those are the same three things `reaction` below does, so they are
908 // done the same way: `mouse_area` for the press and the two edges of
909 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago910 "avatar" => {
911 let size = n.num("size").unwrap_or(32.0) as f32;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago912 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago913 Some(handle) => widget::image(handle)
914 .width(size)
915 .height(size)
916 .content_fit(ContentFit::Cover)
917 .border_radius(size / 2.0)
918 .into(),
919 None => {
920 let initial: String = n
921 .label()
922 .trim_start_matches(|c: char| !c.is_alphanumeric())
923 .chars()
924 .next()
925 .map(|c| c.to_uppercase().collect())
926 .unwrap_or_default();
927 widget::container(widget::text(initial).size(size * 0.45))
928 .center(Length::Fixed(size))
929 .class(filled(name_colour(n.label()), size / 2.0))
930 .into()
931 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago932 };
933 // The hover is reported whether or not the face is enabled: it
934 // says where the pointer is, which is true of an insensitive
935 // picture too. The press is not — an insensitive subtree is out
936 // of interaction, which is what `enabled` means here.
937 let mut area = widget::mouse_area(face)
938 .on_enter(Message::Hover(id))
939 .on_exit(Message::Unhover(id));
940 if enabled {
941 area = area
942 .on_press(Message::Click(id))
943 .interaction(cosmic::iced::mouse::Interaction::Pointer);
944 }
945 if n.children.is_empty() {
946 area.into()
947 } else {
948 widget::tooltip(
949 area,
950 Column::with_children(children(false)).spacing(4),
951 widget::tooltip::Position::Bottom,
952 )
953 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago954 }
955 }
956 // A pill: an emoji, how many people, and whether you are one of them.
957 // What the client hangs under it is its hover card, shown while the
958 // pointer is on the pill.
959 "reaction" => {
960 let glyph = match n.str("emoji") {
961 "" => n.label(),
962 e => e,
963 };
964 let size = n.num("size").unwrap_or(16.0) as f32;
965 let mut content = Row::new()
966 .spacing(4)
967 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago968 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago969 let count = n.num("count").unwrap_or(0.0);
970 if count > 0.0 {
971 content = content.push(widget::text::caption(format!("{count}")));
972 }
973 let class = if n.bool("mine") == Some(true) {
974 widget::button::ButtonClass::Suggested
975 } else {
976 widget::button::ButtonClass::Standard
977 };
978 let pill = widget::button::custom(content)
979 .padding([2, 8])
980 .class(class)
981 .on_press_maybe(enabled.then_some(Message::Click(id)));
982 let pill = widget::mouse_area(pill)
983 .on_enter(Message::Hover(id))
984 .on_exit(Message::Unhover(id));
985 if n.children.is_empty() {
986 pill.into()
987 } else {
988 widget::tooltip(
989 pill,
990 Column::with_children(children(false)).spacing(4),
991 widget::tooltip::Position::Bottom,
992 )
993 .into()
994 }
995 }
996 // One tag for both kinds of picture, as in libvidya. `feed` is live
997 // pixels pushed under a name, which nothing pushes here yet, so it
998 // holds the slot the layout gave it.
999 "image" => {
1000 let max_w = n.num("max-width").map(|v| v as f32);
1001 let max_h = n.num("max-height").map(|v| v as f32);
1002 if !n.str("feed").is_empty() {
1003 let w = max_w.unwrap_or(160.0);
1004 let h = max_h.unwrap_or(w * 0.75);
1005 widget::container(widget::text::caption("video"))
1006 .center_x(Length::Fixed(w))
1007 .center_y(Length::Fixed(h))
1008 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1009 .into()
1010 } else if let Some(handle) = picture(n.str("src")) {
1011 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1012 if n.bool("fit") == Some(true) {
1013 image = image.width(Length::Fill).height(Length::Fill);
1014 } else if let Some(size) = n.num("size") {
1015 image = image.width(size as f32).height(size as f32);
1016 }
1017 let mut bounded = widget::container(image);
1018 if let Some(w) = max_w {
1019 bounded = bounded.max_width(w);
1020 }
1021 if let Some(h) = max_h {
1022 bounded = bounded.max_height(h);
1023 }
1024 clickable(bounded.into(), id, enabled)
1025 } else {
1026 widget::Space::new().width(0).height(0).into()
1027 }
1028 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1029 "checkbutton" => {
1030 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1031 if enabled {
1032 check = check.on_toggle(move |on| Message::Toggled(id, on));
1033 }
1034 check.into()
1035 }
1036 "entry" => {
1037 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1038 if enabled {
1039 entry = entry
1040 .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 ago1041 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1042 .on_submit(move |_| Message::Activate(id));
1043 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1044 let width = match width_request(n) {
1045 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1046 _ => Length::Fill,
1047 };
1048 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1049 }
1050 "separator" => widget::divider::horizontal::default().into(),
1051 "spacer" => {
1052 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1053 if n.str("expand").is_empty() {
1054 widget::Space::new().width(size).height(size).into()
1055 } else {
1056 widget::Space::new().width(Length::Fill).height(size).into()
1057 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1058 }
1059 "progress" => {
1060 let bar =
1061 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1062 if n.label().is_empty() {
1063 bar.into()
1064 } else {
1065 Column::new()
1066 .spacing(4)
1067 .push(widget::text::caption(n.label()))
1068 .push(bar)
1069 .into()
1070 }
1071 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1072 // The one node that is not painted where it stands. libcosmic puts a
1073 // dialog up itself, centred over the window and dimming what is
1074 // behind it — `Application::dialog` is the hook, and it is asked for
1075 // one separately from `view`. So the tree carries the dialog wherever
1076 // the client found it convenient to write it, `App::dialog` goes and
1077 // finds it there, and this leaves nothing behind in the layout. A
1078 // node rendered in both places would be painted twice.
1079 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1080 // Kept rather than refused, as in libvidya: a tag this backend has not
1081 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1082 _ => Column::with_children(children(false))
1083 .spacing(spacing)
1084 .padding(margins(n))
1085 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1086 };
1087
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1088 // The containers and the entry size themselves above; anything else asked
1089 // for a width gets it from a wrapper.
1090 match (n.tag.as_str(), width_request(n)) {
1091 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1092 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1093 }
1094}
1095
1096// --- the C ABI: the loop -------------------------------------------------------
1097
1098static TITLE: Mutex<String> = Mutex::new(String::new());
1099
1100/// The window's title, read when `cosmic_run` opens it. A call of its own
1101/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1102/// `cosmic_run` has to be one.
1103///
1104/// # Safety
1105/// `title` is null or a NUL-terminated string.
1106#[no_mangle]
1107pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1108 let title = borrowed(title);
1109 guard((), || *lock(&TITLE) = title)
1110}
1111
1112/// Open the window and run libcosmic until it closes. Blocks; call it on the
1113/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1114///
1115/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1116/// in this process — winit's event loop cannot be made twice.
1117#[no_mangle]
1118pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1119 let status = guard(1, || {
1120 let title = lock(&TITLE).clone();
1121 if RAN.swap(true, SeqCst) {
1122 log::error!("jolt-cosmic: a window already ran in this process");
1123 return 2;
1124 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1125 // The size asked for, until libcosmic reports the one it got.
1126 WINDOW_W.store(width.max(1) as u32, SeqCst);
1127 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1128 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1129 let mut settings = cosmic::app::Settings::default().size(size);
1130 match mode {
1131 1 => settings = settings.theme(cosmic::Theme::dark()),
1132 2 => settings = settings.theme(cosmic::Theme::light()),
1133 _ => {}
1134 }
1135 match cosmic::app::run::<App>(settings, title) {
1136 Ok(()) => 0,
1137 Err(err) => {
1138 eprintln!("jolt-cosmic: {err}");
1139 1
1140 }
1141 }
1142 });
1143 // Outside the guard, so a panic in libcosmic still releases the worker.
1144 *lock(&TO_APP) = None;
1145 CLOSED.store(true, SeqCst);
1146 BELL.notify_all();
1147 status
1148}
1149
1150/// 1 once `cosmic_run` has returned.
1151#[no_mangle]
1152pub extern "C" fn cosmic_should_close() -> c_int {
1153 c_int::from(CLOSED.load(SeqCst))
1154}
1155
1156/// Close the window. Asked before the window exists, it closes on opening.
1157#[no_mangle]
1158pub extern "C" fn cosmic_quit() {
1159 guard((), || {
1160 QUIT_ASKED.store(true, SeqCst);
1161 tell_app(Wake::Quit);
1162 })
1163}
1164
1165/// Publish the edits since the last commit. Answers 1 when there were any.
1166#[no_mangle]
1167pub extern "C" fn cosmic_tree_commit() -> c_int {
1168 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1169 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1170 let snapshot = {
1171 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1172 // A pass that only settled events still publishes, so a control
1173 // holding typed text over an older commit lets go of it.
1174 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1175 return 0;
1176 }
1177 e.dirty = false;
1178 Arc::new(e.tree.clone())
1179 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1180 {
1181 let mut committed = lock(&COMMITTED);
1182 *committed = snapshot;
1183 COMMITTED_SETTLED.store(settled, SeqCst);
1184 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1185 tell_app(Wake::Tree);
1186 1
1187 })
1188}
1189
1190/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1191/// closing. Answers 1 when an event is waiting.
1192#[no_mangle]
1193pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1194 guard(0, || {
1195 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1196 let (mut inbox, _) = BELL
1197 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1198 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1199 })
1200 .unwrap_or_else(|poisoned| poisoned.into_inner());
1201 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1202 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1203 c_int::from(!inbox.queue.is_empty())
1204 })
1205}
1206
1207/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1208#[no_mangle]
1209pub extern "C" fn cosmic_wake() {
1210 guard((), || {
1211 lock(&INBOX).woken = true;
1212 BELL.notify_all();
1213 })
1214}
1215
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1216// --- the C ABI: the window and the desktop -----------------------------------------
1217
1218/// The window's width in points; the size asked for until it has opened.
1219#[no_mangle]
1220pub extern "C" fn cosmic_window_width() -> c_int {
1221 WINDOW_W.load(SeqCst) as c_int
1222}
1223
1224#[no_mangle]
1225pub extern "C" fn cosmic_window_height() -> c_int {
1226 WINDOW_H.load(SeqCst) as c_int
1227}
1228
1229/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1230/// there is no window to ask from; the choice arrives through
1231/// `cosmic_picked_image`.
1232#[no_mangle]
1233pub extern "C" fn cosmic_pick_image() -> c_int {
1234 guard(0, || {
1235 if lock(&TO_APP).is_none() {
1236 return 0;
1237 }
1238 *lock(&PICK) = Pick::Open;
1239 tell_app(Wake::PickImage);
1240 1
1241 })
1242}
1243
1244/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1245/// was chosen since the last call; 0 while the chooser is open, after it was
1246/// cancelled, or when the picture could not be read.
1247///
1248/// # Safety
1249/// `path` is null or a NUL-terminated string.
1250#[no_mangle]
1251pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1252 let path = borrowed(path);
1253 guard(0, || {
1254 let chosen = {
1255 let mut pick = lock(&PICK);
1256 match std::mem::replace(&mut *pick, Pick::Idle) {
1257 Pick::Chosen(chosen) => chosen,
1258 other => {
1259 *pick = other;
1260 return 0;
1261 }
1262 }
1263 };
1264 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1265 Ok(()) => 1,
1266 Err(err) => {
1267 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1268 0
1269 }
1270 }
1271 })
1272}
1273
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1274/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1275/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1276/// already taken, or when the file could not be written.
1277///
1278/// # Safety
1279/// `path` is null or a NUL-terminated string.
1280#[no_mangle]
1281pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1282 let path = borrowed(path);
1283 guard(0, || {
1284 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1285 return 0;
1286 };
1287 match std::fs::write(&*path, png) {
1288 Ok(()) => 1,
1289 Err(err) => {
1290 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1291 0
1292 }
1293 }
1294 })
1295}
1296
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1297// --- the C ABI: events -----------------------------------------------------------
1298
1299static EVENT_NAME: Scratch = Scratch::new();
1300static EVENT_TEXT: Scratch = Scratch::new();
1301
1302/// Dequeue one event; 1 while there was one. The accessors describe it.
1303#[no_mangle]
1304pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1305 guard(0, || {
1306 let mut inbox = lock(&INBOX);
1307 let next = inbox.queue.pop_front();
1308 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1309 if let Some(e) = &next {
1310 inbox.taken = e.seq;
1311 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1312 inbox.current = next;
1313 c_int::from(got)
1314 })
1315}
1316
1317#[no_mangle]
1318pub extern "C" fn cosmic_tree_event_node() -> c_int {
1319 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1320}
1321
1322#[no_mangle]
1323pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1324 guard(empty_str(), || {
1325 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1326 })
1327}
1328
1329#[no_mangle]
1330pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1331 guard(empty_str(), || {
1332 let text = lock(&INBOX)
1333 .current
1334 .as_ref()
1335 .map(|e| e.text.clone())
1336 .unwrap_or_default();
1337 EVENT_TEXT.lend(text)
1338 })
1339}
1340
1341#[no_mangle]
1342pub extern "C" fn cosmic_tree_event_num() -> f64 {
1343 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1344}
1345
1346// --- the C ABI: nodes --------------------------------------------------------------
1347
1348static PROPS: Scratch = Scratch::new();
1349static DUMP: Scratch = Scratch::new();
1350
1351#[no_mangle]
1352pub extern "C" fn cosmic_tree_root() -> c_int {
1353 guard(0, || edit(Tree::root))
1354}
1355
1356/// # Safety
1357/// `tag` is null or a NUL-terminated string.
1358#[no_mangle]
1359pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1360 let tag = borrowed(tag);
1361 guard(0, || edit(|t| t.new_node(&tag)))
1362}
1363
1364#[no_mangle]
1365pub extern "C" fn cosmic_node_free(node: c_int) {
1366 guard((), || edit(|t| t.free(node)))
1367}
1368
1369#[no_mangle]
1370pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1371 guard(0, || c_int::from(read(|t| t.exists(node))))
1372}
1373
1374/// # Safety
1375/// `key` and `value` are null or NUL-terminated strings.
1376#[no_mangle]
1377pub unsafe extern "C" fn cosmic_node_set_str(
1378 node: c_int,
1379 key: *const c_char,
1380 value: *const c_char,
1381) {
1382 let (key, value) = (borrowed(key), borrowed(value));
1383 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1384}
1385
1386/// # Safety
1387/// `key` is null or a NUL-terminated string.
1388#[no_mangle]
1389pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1390 let key = borrowed(key);
1391 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1392}
1393
1394/// # Safety
1395/// `key` is null or a NUL-terminated string.
1396#[no_mangle]
1397pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1398 let key = borrowed(key);
1399 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1400}
1401
1402#[no_mangle]
1403pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1404 guard((), || edit(|t| t.clear_props(node)))
1405}
1406
1407#[no_mangle]
1408pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1409 guard(empty_str(), || {
1410 PROPS.lend(read(|t| {
1411 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1412 }))
1413 })
1414}
1415
1416#[no_mangle]
1417pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1418 guard(0, || {
1419 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1420 })
1421}
1422
1423#[no_mangle]
1424pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1425 guard(0, || {
1426 read(|t| {
1427 t.get(node)
1428 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1429 .unwrap_or(0)
1430 })
1431 })
1432}
1433
1434#[no_mangle]
1435pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1436 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1437}
1438
1439/// Unparents AND frees `child` with everything under it.
1440#[no_mangle]
1441pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1442 guard((), || edit(|t| t.remove(parent, child)))
1443}
1444
1445#[no_mangle]
1446pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1447 guard(0, || {
1448 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1449 })
1450}
1451
1452#[no_mangle]
1453pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1454 guard(0, || {
1455 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1456 })
1457}
1458
1459/// The subtree at `node` as hiccup; 0 is the root.
1460#[no_mangle]
1461pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1462 guard(empty_str(), || {
1463 DUMP.lend(read(|t| {
1464 let id = if node == 0 { t.root_id() } else { node };
1465 t.dump(id)
1466 }))
1467 })
1468}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1469
1470#[cfg(test)]
1471mod tests {
1472 use super::*;
1473
1474 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1475 let id = t.new_node(tag);
1476 assert!(t.append(parent, id));
1477 id
1478 }
1479
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1480 #[test]
1481 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1482 let mut t = Tree::default();
1483 let root = t.root();
1484 let entry = node(&mut t, root, "entry");
1485 t.set(entry, "text", Prop::Str("a".into()));
1486 let mut tree = Arc::new(t);
1487 let mut typed = HashMap::new();
1488 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1489
1490 // Rendered before the worker saw the "b".
1491 keep_typed(&mut tree, &mut typed, 1);
1492 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1493 assert_eq!(typed.len(), 1);
1494
1495 // Rendered after: the component cleared its draft, and that stands.
1496 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1497 keep_typed(&mut tree, &mut typed, 2);
1498 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1499 assert!(typed.is_empty());
1500 }
1501
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1502 #[test]
1503 fn a_zero_width_request_is_no_request() {
1504 let mut t = Tree::default();
1505 let root = t.root();
1506 let column = node(&mut t, root, "vbox");
1507 t.set(column, "width-request", Prop::Num(0.0));
1508 assert_eq!(width_request(t.get(column).unwrap()), None);
1509 t.set(column, "width-request", Prop::Num(260.0));
1510 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1511 }
1512
1513 #[test]
1514 fn a_scroll_is_named_by_its_scroll_key() {
1515 let mut t = Tree::default();
1516 let root = t.root();
1517 let list = node(&mut t, root, "scroll");
1518 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1519 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1520 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1521 }
1522
1523 #[test]
1524 fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {
1525 let mut t = Tree::default();
1526 let root = t.root();
1527 let list = node(&mut t, root, "scroll");
1528 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1529 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1530 let before = t.clone();
1531 t.set(rows[3], "scroll-here", Prop::Bool(true));
1532
1533 let asks = scroll_asks(&before, &t);
1534 assert_eq!(asks.len(), 1);
1535 assert!(!asks[0].fresh);
1536 assert_eq!(asks[0].reveal, Some(0.75));
1537
1538 let again = scroll_asks(&t, &t);
1539 assert_eq!(again[0].reveal, None);
1540 }
1541
1542 #[test]
1543 fn a_jump_is_the_counter_moving() {
1544 let mut t = Tree::default();
1545 let root = t.root();
1546 let list = node(&mut t, root, "scroll");
1547 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1548 let before = t.clone();
1549 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1550 let asks = scroll_asks(&before, &t);
1551 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1552 }
1553}