nandi/jolt-nativepublic Fork 0
5ba95e0164dfaf9110357b5e041001f98d4f13a9
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 · 1781 lines · 68.1 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
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago22mod rows;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago23mod tree;
24
25pub use tree::{Node, Prop, Tree};
26
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago27use std::collections::{HashMap, HashSet, VecDeque};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago28use std::ffi::{c_char, c_int};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago29use std::path::PathBuf;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago31use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
32use std::time::Duration;
33
34use cosmic::app::{Core, Task};
35use cosmic::iced::futures::channel::mpsc;
36use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago37use cosmic::iced::widget::container::Style as ContainerStyle;
38use 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 ago39use cosmic::iced::widget::text::Wrapping;
40use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago41use cosmic::widget::{self, Column, Row};
42use cosmic::{ApplicationExt, Element};
43use jolt_abi::{borrowed, empty_str, guard, Scratch};
44
45fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
46 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
47}
48
49// --- the arena ---------------------------------------------------------------
50
51struct Edits {
52 tree: Tree,
53 /// Set by every mutation, cleared by a commit that published it.
54 dirty: bool,
55}
56
57static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
58 Mutex::new(Edits {
59 tree: Tree::default(),
60 dirty: false,
61 })
62});
63
64/// What `view` paints: the tree as of the last commit.
65static 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 ago66/// The inbox's `settled` when that commit was made. Written under
67/// `COMMITTED`'s lock, so the two are read as a pair.
68static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago69
70fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
71 let mut e = lock(&EDITS);
72 e.dirty = true;
73 f(&mut e.tree)
74}
75
76fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
77 f(&lock(&EDITS).tree)
78}
79
80// --- events, towards jolt ----------------------------------------------------
81
82struct Event {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago83 seq: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago84 node: i32,
85 name: &'static str,
86 text: String,
87 num: f64,
88}
89
90struct Inbox {
91 queue: VecDeque<Event>,
92 current: Option<Event>,
93 woken: bool,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago94 /// The sequence number of the last event posted.
95 posted: u64,
96 /// The sequence number of the last event the worker dequeued.
97 taken: u64,
98 /// `taken` as of the worker's last `cosmic_wait`. Every event up to here
99 /// had its handler run on an earlier pass, so whatever it re-rendered is
100 /// in the arena by the next commit.
101 settled: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago102}
103
104static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
105 queue: VecDeque::new(),
106 current: None,
107 woken: false,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago108 posted: 0,
109 taken: 0,
110 settled: 0,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago111});
112static BELL: Condvar = Condvar::new();
113
114fn 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 ago115 post_seq(node, name, text, num);
116}
117
118/// Queue an event for the worker; answers its sequence number.
119fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 {
120 let seq = {
121 let mut inbox = lock(&INBOX);
122 inbox.posted += 1;
123 let seq = inbox.posted;
124 inbox.queue.push_back(Event {
125 seq,
126 node,
127 name,
128 text,
129 num,
130 });
131 seq
132 };
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago133 BELL.notify_all();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago134 seq
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago135}
136
137// --- wakes, towards iced -----------------------------------------------------
138
139enum Wake {
140 Tree,
141 Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago142 PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago143}
144
145static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
146static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
147static RAN: AtomicBool = AtomicBool::new(false);
148static CLOSED: AtomicBool = AtomicBool::new(false);
149
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago150/// The window's size in points, as libcosmic last reported it. A client that
151/// lays columns out by arithmetic — frq sizes its message list against the
152/// people panel beside it — has to be able to ask.
153static WINDOW_W: AtomicU32 = AtomicU32::new(0);
154static WINDOW_H: AtomicU32 = AtomicU32::new(0);
155
156/// Where a picture chooser opened by `cosmic_pick_image` has got to.
157enum Pick {
158 Idle,
159 Open,
160 Chosen(PathBuf),
161}
162
163static PICK: Mutex<Pick> = Mutex::new(Pick::Idle);
164
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago165/// The picture a Ctrl+V found on the clipboard, held until the worker asks for
166/// it with `cosmic_clipboard_image_png`.
167static CLIPBOARD_PNG: Mutex<Option<Vec<u8>>> = Mutex::new(None);
168
169/// The clipboard read as PNG. Only image/png is asked for: every desktop that
170/// puts a picture on a clipboard puts one there as PNG too.
171struct ClipboardPng(Vec<u8>);
172
173impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng {
174 fn allowed() -> std::borrow::Cow<'static, [String]> {
175 std::borrow::Cow::Owned(vec!["image/png".to_owned()])
176 }
177}
178
179impl TryFrom<(Vec<u8>, String)> for ClipboardPng {
180 type Error = ();
181
182 fn try_from((bytes, _mime): (Vec<u8>, String)) -> Result<Self, ()> {
183 if bytes.is_empty() {
184 Err(())
185 } else {
186 Ok(Self(bytes))
187 }
188 }
189}
190
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago191/// Named for emoji rather than left to fallback: the first face with a glyph
192/// for a smiley is often a monochrome one, and the pill then shows an outline.
193const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji");
194
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago195fn tell_app(wake: Wake) {
196 if let Some(tx) = lock(&TO_APP).as_ref() {
197 let _ = tx.unbounded_send(wake);
198 }
199}
200
201/// The subscription's stream. It opens with a `Tree` wake so a commit made
202/// between `init` and the subscription starting is not missed, and repeats a
203/// quit asked for before there was anyone to tell.
204fn wakes() -> impl Stream<Item = Message> {
205 let (tx, rx) = mpsc::unbounded();
206 let _ = tx.unbounded_send(Wake::Tree);
207 if QUIT_ASKED.load(SeqCst) {
208 let _ = tx.unbounded_send(Wake::Quit);
209 }
210 *lock(&TO_APP) = Some(tx);
211 rx.map(|wake| match wake {
212 Wake::Tree => Message::Tree,
213 Wake::Quit => Message::Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago214 Wake::PickImage => Message::PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago215 })
216}
217
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago218// --- scroll areas ------------------------------------------------------------
219
220/// Where a scroll area was left, kept by name rather than on the widget.
221///
222/// iced keeps a scrollable's offset in its widget tree, and a widget that is
223/// unmounted and mounted again starts at the top. glimmer clients unmount
224/// lists all the time — frq's lightbox is a screen, so looking at a picture
225/// takes the backlog away — so the place is remembered here, under the
226/// `scroll-key` the client names the list by, and put back when it returns.
227struct ScrollMemo {
228 /// Whether the reader is at the newest line. A `stick-to-bottom` list
229 /// follows what arrives only while this holds.
230 at_end: bool,
231 offset_y: f32,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago232 /// How tall the viewport was when the reader last moved it, which is what
233 /// a jump centres a row in. Zero until they have: a list nobody has
234 /// scrolled has no reported height, and a row put in the middle of a
235 /// viewport of nothing is a row put at the top — which is the right answer
236 /// to give when the height is not known.
237 height: f32,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago238}
239
240/// Two points of slack: a viewport scrolled to its end by a fractional
241/// offset is still at the end.
242const AT_END_SLACK: f32 = 2.0;
243
244fn scroll_name(n: &Node, id: i32) -> String {
245 match n.str("scroll-key") {
246 "" => format!("node-{id}"),
247 key => key.to_owned(),
248 }
249}
250
251fn scroll_id(name: &str) -> widget::Id {
252 widget::Id::new(format!("jolt-scroll-{name}"))
253}
254
255fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
256 if let Some(n) = t.get(id) {
257 f(id, n);
258 for child in &n.children {
259 walk(t, *child, f);
260 }
261 }
262}
263
264/// What a commit asks of one scroll area.
265struct ScrollAsk {
266 name: String,
267 stick: bool,
268 /// The `scroll-to-bottom` counter, and what it was in the tree before.
269 tick: Option<f64>,
270 tick_before: Option<f64>,
271 /// Not in the tree before this commit: mounted, or mounted again.
272 fresh: bool,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago273 /// The row asking to be shown, if one is.
274 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago275}
276
277/// Every scroll area in `now`, and what changed about each since `before`.
278fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
279 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
280 walk(before, before.root_id(), &mut |id, n| {
281 if n.tag == "scroll" {
282 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
283 }
284 });
285
286 let mut asks = Vec::new();
287 walk(now, now.root_id(), &mut |id, n| {
288 if n.tag != "scroll" {
289 return;
290 }
291 let name = scroll_name(n, id);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago292 // The row holding whatever asked to be shown.
293 //
294 // The row, because a row is what `rows::Rows` writes a place down for;
295 // and the row rather than a guess at where it sits, because this used
296 // to answer with its index over the row count. That is a fraction of
297 // the scroll RANGE and not of the content — the two agree only when
298 // the viewport is exactly one row tall — and it took every row for the
299 // same height besides, in a backlog that puts a one-line message next
300 // to a picture. The landing was out by up to a viewport, worst in the
301 // middle of a list.
302 //
303 // While it is asking, not only on the commit the ask arrives. A row
304 // that is not laid out yet has no place written down for it, and the
305 // ask is over in half a second: asking again each commit is what lets
306 // a jump into a conversation the client has only just switched to land
307 // on the frame the rows finally exist.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago308 let mut reveal = None;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago309 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago310 let mut asked = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago311 walk(now, *row, &mut |_, node| {
312 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago313 });
314 if asked {
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago315 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago316 break;
317 }
318 }
319 asks.push(ScrollAsk {
320 fresh: !named_before.contains_key(&name),
321 tick_before: named_before.get(&name).copied().flatten(),
322 tick: n.num("scroll-to-bottom"),
323 stick: n.bool("stick-to-bottom") == Some(true),
324 reveal,
325 name,
326 });
327 });
328 asks
329}
330
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago331/// Where the rows of the scroll area called `name` were last laid out.
332///
333/// By name and not by node, because a scroll area outlives the node ids of a
334/// tree that is rebuilt under it — the same list, and the reader's place in
335/// it, is the thing `scroll-key` names.
336fn placements(name: &str) -> rows::Placements {
337 static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
338 LazyLock::new(|| Mutex::new(HashMap::new()));
339 lock(&BOOKS).entry(name.to_owned()).or_default().clone()
340}
341
342/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
343/// of a viewport `viewport` tall.
344///
345/// The middle rather than the top edge: a line answered three days ago is read
346/// with what was said around it, and a jump that pins it to the ceiling shows
347/// only what came after.
348///
349/// Never above the start of the content — a negative offset is not a place —
350/// and the top edge is the answer while the viewport's height is unknown,
351/// which it is until the reader has scrolled the list once. A row centred in a
352/// viewport of nothing is a row at the top, which is the same answer said
353/// twice, but it is worth being the one that is said on purpose.
354fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
355 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
356}
357
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago358/// Whether to say out loud what every jump decided, on stderr.
359///
360/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
361/// and which of them is wrong is not a thing anyone can tell from a window
362/// that scrolled to the wrong place.
363fn scroll_log() -> bool {
364 static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
365 *ON
366}
367
368/// How many frames a reveal keeps trying for.
369///
370/// A row is measured by the layout that draws it, so the frame a jump is asked
371/// on is a frame too early: the places written down are the ones from before
372/// the room changed. Twenty frames is a third of a second at sixty, which is
373/// longer than a screen takes to build and shorter than a reader waits before
374/// deciding nothing happened.
375const REVEAL_TRIES: u8 = 20;
376
377/// The row of the scroll area called `name` that is asking to be shown, as the
378/// tree has it now.
379///
380/// Asked again on every attempt rather than carried, because a row is not the
381/// same node for long. A buffer that takes a line while a jump is landing is
382/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
383/// node 587 now — so a retry holding the old number would look up a place for
384/// a row nobody has, and go on failing until it gave up. Which room a reader
385/// jumped into decided whether it worked, and that is exactly as strange as
386/// it sounds until you see what it depends on.
387fn asking_row(t: &Tree, name: &str) -> Option<i32> {
388 let mut found = None;
389 walk(t, t.root_id(), &mut |id, n| {
390 if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
391 return;
392 }
393 for row in &n.children {
394 let mut asked = false;
395 walk(t, *row, &mut |_, node| {
396 asked |= node.bool("scroll-here") == Some(true);
397 });
398 if asked {
399 found = Some(*row);
400 break;
401 }
402 }
403 });
404 found
405}
406
407/// Ask to be taken to the row of `name` that wants showing — now if its place
408/// is known, and on the next frame if it is not.
409///
410/// A task that is already finished is not a wasted frame: iced takes its
411/// message on the next pass of the loop, which is after this frame has been
412/// laid out — and being laid out is exactly what the row has to have done for
413/// there to be an answer.
414fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
415 match placements(&name).get(row) {
416 Some((top, height)) => {
417 let y = centred_offset(top, height, viewport);
418 if scroll_log() {
419 eprintln!(
420 "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
421 );
422 }
423 iced_scrollable::scroll_to(scroll_id(&name), AbsoluteOffset { x: None, y: Some(y) })
424 }
425 None => {
426 if scroll_log() {
427 eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
428 }
429 Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
430 }
431 }
432}
433
434/// How many rows the scroll area called `name` has, and how many of them are
435/// asking to be shown. For the log alone.
436fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
437 let mut shape = (0, 0);
438 walk(t, t.root_id(), &mut |id, n| {
439 if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
440 return;
441 }
442 shape.0 = n.children.len();
443 for row in &n.children {
444 let mut asked = false;
445 walk(t, *row, &mut |_, node| {
446 asked |= node.bool("scroll-here") == Some(true);
447 });
448 if asked {
449 shape.1 += 1;
450 }
451 }
452 });
453 shape
454}
455
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago456fn snap_to_end(name: &str) -> Task<Message> {
457 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
458}
459
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago460// --- the app -----------------------------------------------------------------
461
462struct App {
463 core: Core,
464 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago465 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago466 /// What the window last wrote back into a control, by node and prop, with
467 /// the sequence number of the event that carried it to the worker.
468 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago469}
470
471#[derive(Clone, Debug)]
472enum Message {
473 Tree,
474 Quit,
475 Click(i32),
476 Toggled(i32, bool),
477 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago478 Paste(i32, String),
479 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago480 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago481 Hover(i32),
482 Unhover(i32),
483 Scrolled(i32, String, Viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago484 /// Show whichever row of this scroll area is asking to be shown, and how
485 /// many more frames to keep trying for. See `reveal`.
486 Reveal(String, u8),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago487 PickImage,
488 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago489}
490
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago491/// Lay what was typed over a commit that has not caught up with it.
492///
493/// libcosmic paints a control from the tree, so a commit rendered before the
494/// worker saw the latest keystroke would put the older text back under the
495/// caret, and the next key would land on that. An entry is let go once a
496/// commit was rendered after its event: from then on the component's own
497/// state is the answer, a draft it cleared included.
498fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) {
499 typed.retain(|&(node, key), (seq, value)| {
500 let Some(n) = tree.get(node) else { return false };
501 if *seq <= settled {
502 return false;
503 }
504 if n.props.get(key) != Some(value) {
505 Arc::make_mut(tree).set(node, key, value.clone());
506 }
507 true
508 });
509}
510
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago511impl App {
512 /// A widget does not own its value: the new state goes into the arena and
513 /// 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 ago514 /// working control, and its next render is what settles it. Then the event
515 /// goes to the worker, and what was written is held over any commit
516 /// rendered before the worker saw it.
517 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 ago518 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 ago519 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
520 let seq = post_seq(node, event, text, num);
521 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago522 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago523
524 /// Take the committed tree, and move every scroll area to where it should
525 /// be now that it has changed.
526 ///
527 /// A snap is relative, so a list snapped to its end stays at its end as
528 /// rows arrive under it, until the reader scrolls away.
529 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago530 let (committed, settled) = {
531 let c = lock(&COMMITTED);
532 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
533 };
534 let before = std::mem::replace(&mut self.tree, committed);
535 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago536 let mut tasks = Vec::new();
537 let mut live = HashSet::new();
538 for ask in scroll_asks(&before, &self.tree) {
539 live.insert(ask.name.clone());
540 let memo = self
541 .scrolls
542 .entry(ask.name.clone())
543 .or_insert(ScrollMemo {
544 at_end: ask.stick,
545 offset_y: 0.0,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago546 height: 0.0,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago547 });
548 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago549 // A row asking to be shown, and a place written down for it by the
550 // last layout. Both, or there is nothing to do yet: the row is
551 // measured on the frame it appears, and the ask stands until it
552 // has been.
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago553 // A row is asking to be shown. Whether or not it can be shown yet,
554 // nothing else may move this list while it is asking: the branch
555 // below would otherwise take a reader who was at the newest line —
556 // which is most readers, most of the time — straight back to it,
557 // and a jump that ends at the bottom of the room reads as a jump
558 // that did nothing.
559 if let Some(row) = ask.reveal {
Leave the end flag to the thing that can see the end e39a374 nandi 7d ago560 // The memo is not told where this lands. `scrolled` is what
561 // knows, and it tells the client only when the answer CHANGES
562 // — so a memo that marked itself away from the end here stole
563 // that change from it: the list moved, the client was never
564 // told, and it went on believing the reader was at the newest
565 // line. Which is a "jump to present" button that does not
566 // appear until the reader scrolls to the present by hand.
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago567 tasks.push(reveal(ask.name.clone(), row, memo.height));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago568 } else if jumped || (ask.stick && memo.at_end) {
569 memo.at_end = true;
570 tasks.push(snap_to_end(&ask.name));
571 } else if ask.fresh {
572 tasks.push(iced_scrollable::scroll_to(
573 scroll_id(&ask.name),
574 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
575 ));
576 }
577 }
578 // A list that was never scrolled keeps no memo worth the space; one
579 // that was keeps its place for when it comes back.
580 self.scrolls
581 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
582 Task::batch(tasks)
583 }
584
585 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
586 let y = viewport.absolute_offset().y;
587 let room = viewport.content_bounds().height - viewport.bounds().height;
588 let at_end = room - y <= AT_END_SLACK;
589 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
590 at_end,
591 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago592 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago593 });
594 let was = memo.at_end;
595 memo.at_end = at_end;
596 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago597 memo.height = viewport.bounds().height;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago598 // "end" or "away", the strings libvidya emits: frq's handler compares
599 // against "end".
600 if was != at_end {
601 let place = if at_end { "end" } else { "away" };
602 post(node, "change", place.to_owned(), 0.0);
603 }
604 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago605}
606
607impl cosmic::Application for App {
608 type Executor = cosmic::executor::Default;
609 type Flags = String;
610 type Message = Message;
611 const APP_ID: &'static str = "dev.jolt.Glimmer";
612
613 fn core(&self) -> &Core {
614 &self.core
615 }
616
617 fn core_mut(&mut self) -> &mut Core {
618 &mut self.core
619 }
620
621 fn init(core: Core, title: String) -> (Self, Task<Message>) {
622 let mut app = App {
623 core,
624 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago625 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago626 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago627 };
628 // libcosmic's `wayland` feature brings `multi-window` with it, which
629 // makes a window title a per-window thing.
630 app.set_header_title(title.clone());
631 let task = match app.core.main_window_id() {
632 Some(id) => app.set_window_title(title, id),
633 None => Task::none(),
634 };
635 (app, task)
636 }
637
638 fn subscription(&self) -> Subscription<Message> {
639 Subscription::run(wakes)
640 }
641
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago642 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
643 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
644 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
645 }
646
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago647 fn update(&mut self, message: Message) -> Task<Message> {
648 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago649 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago650 Message::Quit => return cosmic::iced::exit(),
651 Message::Click(node) => post(node, "click", String::new(), 0.0),
652 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago653 let num = f64::from(u8::from(on));
654 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago655 }
656 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago657 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
658 }
659 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
660 // clipboard holding a picture has none, so the field comes back as
661 // it was. That is the paste worth reporting: the picture is read
662 // here, where the clipboard is, and `paste-empty` goes to the
663 // worker, which collects it with `cosmic_clipboard_image_png`.
664 Message::Paste(node, text) => {
665 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
666 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
667 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
668 }
669 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
670 }
671 Message::PastedPicture(node, png) => {
672 *lock(&CLIPBOARD_PNG) = png;
673 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago674 }
675 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago676 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
677 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
678 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago679 // The row was not laid out when the jump was asked for. Look
680 // again, and keep looking for a few frames: a room the reader has
681 // only just been taken to has to be built before its lines have
682 // anywhere to be.
683 Message::Reveal(name, tries) => {
684 let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
685 let place = asking_row(&self.tree, &name)
686 .and_then(|row| placements(&name).get(row));
687 if let Some((top, height)) = place {
Leave the end flag to the thing that can see the end e39a374 nandi 7d ago688 // Nothing written down here either, for the reason the
689 // commit path gives: where this ends up is `scrolled`'s to
690 // report, and its report is what the client hears.
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago691 let y = centred_offset(top, height, viewport);
692 return iced_scrollable::scroll_to(
693 scroll_id(&name),
694 AbsoluteOffset { x: None, y: Some(y) },
695 );
696 }
697 if scroll_log() {
698 let asking = asking_row(&self.tree, &name);
699 let (rows, here) = scroll_shape(&self.tree, &name);
700 eprintln!(
701 "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
702 {rows} rows, {here} asking to be shown, \
703 {} placed, viewport {viewport}",
704 placements(&name).len()
705 );
706 }
707 // Not landed yet. Keep trying for the whole budget rather
708 // than stopping the moment nothing is asking: a room the
709 // reader has just been taken to is built over several frames,
710 // and one where the rows are not in the tree yet looks exactly
711 // like a jump that is over. It is not over, it is early.
712 if tries > 0 {
713 return Task::future(async move {
714 cosmic::Action::App(Message::Reveal(name, tries - 1))
715 });
716 }
717 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago718 // The desktop's own chooser, through the portal, on libcosmic's
719 // executor: it is a D-Bus round trip, and the window keeps
720 // painting while it is open.
721 Message::PickImage => {
722 return Task::perform(
723 async {
724 rfd::AsyncFileDialog::new()
725 .set_title("Choose a picture")
726 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
727 .pick_file()
728 .await
729 .map(|file| file.path().to_path_buf())
730 },
731 |path| cosmic::Action::App(Message::Picked(path)),
732 );
733 }
734 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago735 }
736 Task::none()
737 }
738
739 fn view(&self) -> Element<'_, Message> {
740 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago741 let root = element(tree, tree.root_id(), true, false);
742 // A dialog that asked not to be modal, put up here rather than handed
743 // to `dialog` below. It is the same widget in the same place — a
744 // `popover` centres it exactly as `cosmic::app` does — and the whole
745 // of the difference is that this one is not told to intercept the
746 // pointer. That matters to anything the pointer opened: a modal
747 // popover hands the window underneath it a cursor that is
748 // `Unavailable`, so a face that opened a dialog on hover never hears
749 // the pointer leave, and what it opened can never close itself.
750 //
751 // The popover is here whether or not there is anything in it, which
752 // `cosmic::app` says of its own in one line and which this learned
753 // the long way: iced keeps a widget's state by where it sits in the
754 // tree, so a wrapper that comes and goes rebuilds everything under
755 // it — and what "everything" holds is the scroll positions. Wrapping
756 // only when a dialog appeared meant resting the pointer on a face
757 // jumped the conversation behind it.
758 let mut popover = widget::popover(root);
759 if let Some(id) = find_dialog(tree, false) {
760 // The dialog reports its own pointer, on the same two events a
761 // face or a pill reports theirs. Without it a dialog the pointer
762 // opened can only be read at arm's length: the client is told the
763 // pointer left what opened it and never told it arrived here, so
764 // the one way to keep it up is not to move — and everything in it
765 // is out of reach.
766 let popup = widget::mouse_area(dialog_of(tree, id))
767 .on_enter(Message::Hover(id))
768 .on_exit(Message::Unhover(id));
769 popover = popover.popup(popup);
770 }
771 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago772 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago773
774 /// The MODAL dialog the tree is carrying, if it is carrying one.
775 ///
776 /// A client says there is one by putting a `dialog` node in the tree and
777 /// says there is not by leaving it out — the same way it says anything
778 /// else. What comes back is libcosmic's own dialog: centred, over a
779 /// dimmed window, and closed by the buttons the client hung on it.
780 ///
781 /// A dialog that says `modal false` does not come back here. This hook is
782 /// the modal one whether the client wants it or not — `cosmic::app` wraps
783 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
784 /// that kind up itself. See `dialog_of`.
785 fn dialog(&self) -> Option<Element<'_, Message>> {
786 let tree = &*self.tree;
787 let id = find_dialog(tree, true)?;
788 Some(dialog_of(tree, id))
789 }
790}
791
792/// The first `dialog` node in the tree whose modality is `modal`.
793///
794/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
795/// not, which is the shape everything else here takes — a prop left out is
796/// the ordinary answer.
797fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
798 let mut found = None;
799 walk(t, t.root_id(), &mut |id, n| {
800 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
801 found = Some(id);
802 }
803 });
804 found
805}
806
807/// One `dialog` node as libcosmic's dialog.
808///
809/// `label` is its heading and `body` the line under it. Children are its
810/// controls, in order, except that a child carrying `slot` "primary" or
811/// "secondary" becomes that action instead — which is where libcosmic puts
812/// the buttons, at the foot and to the right.
813fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
814 let Some(n) = t.get(id) else {
815 return widget::Space::new().width(0).height(0).into();
816 };
817 let mut d = widget::dialog();
818 if !n.label().is_empty() {
819 d = d.title(n.label().to_owned());
820 }
821 if !n.str("body").is_empty() {
822 d = d.body(n.str("body").to_owned());
823 }
824 if let Some(w) = n.num("max-width") {
825 d = d.max_width(w as f32);
826 }
827 for child in &n.children {
828 let Some(c) = t.get(*child) else { continue };
829 let el = element(t, *child, true, false);
830 d = match c.str("slot") {
831 "primary" => d.primary_action(el),
832 "secondary" => d.secondary_action(el),
833 _ => d.control(el),
834 };
835 }
836 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago837}
838
839// --- props into layout -----------------------------------------------------------
840
841/// `margin` all round, with `margin-top` and its siblings overriding a side.
842fn margins(n: &Node) -> Padding {
843 let all = n.num("margin").unwrap_or(0.0) as f32;
844 let side = |key| n.num(key).map_or(all, |v| v as f32);
845 Padding {
846 top: side("margin-top"),
847 right: side("margin-right"),
848 bottom: side("margin-bottom"),
849 left: side("margin-left"),
850 }
851}
852
853/// A width the client asked for. Zero is the client saying "none": frq writes
854/// `:width-request 0` on its message column whenever the people panel is shut,
855/// and taken literally that is a backlog laid out zero points wide.
856fn width_request(n: &Node) -> Option<f32> {
857 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
858}
859
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago860/// `align`, or `default` where it is not set. A row centres its children on
861/// the cross axis by default — a label beside a button otherwise sits against
862/// the top of the button — and a column starts them at the left.
863fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago864 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago865 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago866 "center" => Alignment::Center,
867 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago868 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago869 }
870}
871
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago872fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
873 cosmic::theme::Container::custom(move |_| ContainerStyle {
874 background: Some(Background::Color(color)),
875 border: Border {
876 radius: radius.into(),
877 ..Border::default()
878 },
879 text_color: Some(Color::WHITE),
880 ..ContainerStyle::default()
881 })
882}
883
884/// A colour for somebody, from their name, so the same person is the same
885/// colour everywhere they appear.
886fn name_colour(name: &str) -> Color {
887 const PALETTE: [(f32, f32, f32); 8] = [
888 (0.83, 0.33, 0.33),
889 (0.85, 0.55, 0.20),
890 (0.62, 0.62, 0.18),
891 (0.30, 0.65, 0.35),
892 (0.20, 0.62, 0.62),
893 (0.30, 0.50, 0.85),
894 (0.55, 0.40, 0.85),
895 (0.80, 0.35, 0.65),
896 ];
897 let hash = name
898 .bytes()
899 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
900 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
901 Color::from_rgb(r, g, b)
902}
903
904/// A picture that answers a click, with the pointer saying so.
905fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
906 if !enabled {
907 return el;
908 }
909 widget::mouse_area(el)
910 .on_press(Message::Click(id))
911 .interaction(cosmic::iced::mouse::Interaction::Pointer)
912 .into()
913}
914
915fn picture(path: &str) -> Option<widget::image::Handle> {
916 (!path.is_empty() && std::path::Path::new(path).exists())
917 .then(|| widget::image::Handle::from_path(path))
918}
919
920// --- the tree into widgets ---------------------------------------------------------
921
922/// One node and everything under it, as widgets.
923///
924/// `enabled` is inherited: an insensitive container takes its whole subtree out
925/// of interaction. `in_row` is whether the parent lays its children out across:
926/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
927/// column in a column takes the width and a column in a row does not take the
928/// row's slack unless it says `fill-height`.
929fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago930 let Some(n) = t.get(id) else {
931 return Column::new().into();
932 };
933 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago934 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago935 // glimmer-jvui's theme spacing, where the client does not say: a list of
936 // cards with nothing between them reads as one slab.
937 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago938 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 ago939
940 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago941 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago942 .width(Length::Fill)
943 .height(Length::Fill)
944 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago945 // Sizes are set only where something asked for one. iced's rows and
946 // columns take `Fill` on an axis from any child that fills it, which is
947 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
948 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
949 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago950 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago951 let across = n.str("orientation") == "horizontal";
952 if across {
953 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago954 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago955 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago956 .align_y(alignment(n, Alignment::Center));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago957 // A row fills the width it is in only when it or something in
958 // it asks to; otherwise a line of buttons would spread out.
959 match width_request(n) {
960 Some(w) => row = row.width(w),
961 None if fill_height => row = row.width(Length::Fill),
962 None => {}
963 }
964 if fill_height {
965 row = row.height(Length::Fill);
966 }
967 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago968 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago969 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago970 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago971 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago972 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago973 match width_request(n) {
974 Some(w) => column = column.width(w),
975 None if fill_height || !in_row => column = column.width(Length::Fill),
976 None => {}
977 }
978 if fill_height {
979 column = column.height(Length::Fill);
980 }
981 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago982 }
983 }
984 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago985 let column = Column::with_children(children(false))
986 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago987 .padding(24)
988 .width(Length::Fill);
989 let mut inner = widget::container(column).width(Length::Fill);
990 if let Some(max) = n.num("max-width") {
991 inner = inner.max_width(max as f32);
992 }
993 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago994 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago995 .height(Length::Fill)
996 .into()
997 }
998 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago999 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 ago1000 if n.tag == "frame" && !n.label().is_empty() {
1001 column = column.push(widget::text::heading(n.label()));
1002 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1003 let card = widget::container(column.extend(children(false)))
1004 .padding(12)
1005 .class(cosmic::theme::Container::Card);
1006 match width_request(n) {
1007 Some(w) => card.width(w).into(),
1008 None if !in_row => card.width(Length::Fill).into(),
1009 None => card.into(),
1010 }
1011 }
1012 // Always fills both ways: a viewport that only fills its width asks its
1013 // column for no height, and is given none. The content is held to its
1014 // own height, since iced will not scroll content that fills the axis it
1015 // scrolls along.
1016 "scroll" => {
1017 let name = scroll_name(n, id);
1018 let content = Column::with_children(children(false))
1019 .spacing(spacing)
1020 .width(Length::Fill)
1021 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1022 // Wrapped in the thing that writes down where each row landed, so
1023 // that "take me to this line" has an answer in points — which is
1024 // the only thing a scroll area can be told. See `rows`.
1025 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1026 widget::scrollable(content)
1027 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1028 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1029 .height(Length::Fill)
1030 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1031 .into()
1032 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1033 // Word wrapping that falls back to breaking inside a word: a URL is one
1034 // word, and it otherwise runs straight past the edge of its column.
1035 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
1036 .wrapping(Wrapping::WordOrGlyph)
1037 .into(),
1038 "label" => widget::text::body(n.label())
1039 .wrapping(Wrapping::WordOrGlyph)
1040 .into(),
1041 "title" => widget::text::title3(n.label())
1042 .wrapping(Wrapping::WordOrGlyph)
1043 .into(),
1044 "title-2" => widget::text::title4(n.label())
1045 .wrapping(Wrapping::WordOrGlyph)
1046 .into(),
1047 "dim-label" => widget::text::caption(n.label())
1048 .wrapping(Wrapping::WordOrGlyph)
1049 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1050 "button" => {
1051 let button = match n.str("kind") {
1052 "primary" => widget::button::suggested(n.label()),
1053 "destructive" => widget::button::destructive(n.label()),
1054 _ => widget::button::standard(n.label()),
1055 };
1056 button
1057 .on_press_maybe(enabled.then_some(Message::Click(id)))
1058 .into()
1059 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1060 "link" => widget::button::link(n.label().to_owned())
1061 .on_press_maybe(enabled.then_some(Message::Click(id)))
1062 .into(),
1063 // A dot that says whether the thing is live, and the words beside it.
1064 "status" => {
1065 let colour = if n.bool("live") == Some(true) {
1066 Color::from_rgb(0.30, 0.72, 0.40)
1067 } else {
1068 Color::from_rgb(0.55, 0.55, 0.55)
1069 };
1070 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
1071 Row::new()
1072 .spacing(6)
1073 .align_y(Alignment::Center)
1074 .push(dot)
1075 .push(widget::text::caption(n.label()))
1076 .into()
1077 }
1078 "spinner" => {
1079 let mut row = Row::new()
1080 .spacing(8)
1081 .align_y(Alignment::Center)
1082 .push(widget::progress_bar::indeterminate_circular().size(16.0));
1083 if !n.label().is_empty() {
1084 row = row.push(widget::text::caption(n.label()));
1085 }
1086 row.into()
1087 }
1088 "emoji" => {
1089 let glyph = match n.str("emoji") {
1090 "" => n.label(),
1091 e => e,
1092 };
1093 widget::text(glyph.to_owned())
1094 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1095 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1096 .into()
1097 }
1098 // A round picture, or the initial on a colour from the name: most
1099 // 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 7d ago1100 //
1101 // And the three things a face is for besides being looked at. It
1102 // painted as a picture and nothing else until now: a client that
1103 // asked a face to answer a click, to report the pointer arriving, or
1104 // to carry a card under it was handed a portrait that did none of
1105 // them — so the profile behind every avatar in the window was
1106 // unreachable, and the hover card written for it never appeared.
1107 // Those are the same three things `reaction` below does, so they are
1108 // done the same way: `mouse_area` for the press and the two edges of
1109 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1110 "avatar" => {
1111 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 7d ago1112 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1113 Some(handle) => widget::image(handle)
1114 .width(size)
1115 .height(size)
1116 .content_fit(ContentFit::Cover)
1117 .border_radius(size / 2.0)
1118 .into(),
1119 None => {
1120 let initial: String = n
1121 .label()
1122 .trim_start_matches(|c: char| !c.is_alphanumeric())
1123 .chars()
1124 .next()
1125 .map(|c| c.to_uppercase().collect())
1126 .unwrap_or_default();
1127 widget::container(widget::text(initial).size(size * 0.45))
1128 .center(Length::Fixed(size))
1129 .class(filled(name_colour(n.label()), size / 2.0))
1130 .into()
1131 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1132 };
1133 // The hover is reported whether or not the face is enabled: it
1134 // says where the pointer is, which is true of an insensitive
1135 // picture too. The press is not — an insensitive subtree is out
1136 // of interaction, which is what `enabled` means here.
1137 let mut area = widget::mouse_area(face)
1138 .on_enter(Message::Hover(id))
1139 .on_exit(Message::Unhover(id));
1140 if enabled {
1141 area = area
1142 .on_press(Message::Click(id))
1143 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1144 }
1145 if n.children.is_empty() {
1146 area.into()
1147 } else {
1148 widget::tooltip(
1149 area,
1150 Column::with_children(children(false)).spacing(4),
1151 widget::tooltip::Position::Bottom,
1152 )
1153 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1154 }
1155 }
1156 // A pill: an emoji, how many people, and whether you are one of them.
1157 // What the client hangs under it is its hover card, shown while the
1158 // pointer is on the pill.
1159 "reaction" => {
1160 let glyph = match n.str("emoji") {
1161 "" => n.label(),
1162 e => e,
1163 };
1164 let size = n.num("size").unwrap_or(16.0) as f32;
1165 let mut content = Row::new()
1166 .spacing(4)
1167 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1168 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1169 let count = n.num("count").unwrap_or(0.0);
1170 if count > 0.0 {
1171 content = content.push(widget::text::caption(format!("{count}")));
1172 }
1173 let class = if n.bool("mine") == Some(true) {
1174 widget::button::ButtonClass::Suggested
1175 } else {
1176 widget::button::ButtonClass::Standard
1177 };
1178 let pill = widget::button::custom(content)
1179 .padding([2, 8])
1180 .class(class)
1181 .on_press_maybe(enabled.then_some(Message::Click(id)));
1182 let pill = widget::mouse_area(pill)
1183 .on_enter(Message::Hover(id))
1184 .on_exit(Message::Unhover(id));
1185 if n.children.is_empty() {
1186 pill.into()
1187 } else {
1188 widget::tooltip(
1189 pill,
1190 Column::with_children(children(false)).spacing(4),
1191 widget::tooltip::Position::Bottom,
1192 )
1193 .into()
1194 }
1195 }
1196 // One tag for both kinds of picture, as in libvidya. `feed` is live
1197 // pixels pushed under a name, which nothing pushes here yet, so it
1198 // holds the slot the layout gave it.
1199 "image" => {
1200 let max_w = n.num("max-width").map(|v| v as f32);
1201 let max_h = n.num("max-height").map(|v| v as f32);
1202 if !n.str("feed").is_empty() {
1203 let w = max_w.unwrap_or(160.0);
1204 let h = max_h.unwrap_or(w * 0.75);
1205 widget::container(widget::text::caption("video"))
1206 .center_x(Length::Fixed(w))
1207 .center_y(Length::Fixed(h))
1208 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1209 .into()
1210 } else if let Some(handle) = picture(n.str("src")) {
1211 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1212 if n.bool("fit") == Some(true) {
1213 image = image.width(Length::Fill).height(Length::Fill);
1214 } else if let Some(size) = n.num("size") {
1215 image = image.width(size as f32).height(size as f32);
1216 }
1217 let mut bounded = widget::container(image);
1218 if let Some(w) = max_w {
1219 bounded = bounded.max_width(w);
1220 }
1221 if let Some(h) = max_h {
1222 bounded = bounded.max_height(h);
1223 }
1224 clickable(bounded.into(), id, enabled)
1225 } else {
1226 widget::Space::new().width(0).height(0).into()
1227 }
1228 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1229 "checkbutton" => {
1230 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1231 if enabled {
1232 check = check.on_toggle(move |on| Message::Toggled(id, on));
1233 }
1234 check.into()
1235 }
1236 "entry" => {
1237 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1238 if enabled {
1239 entry = entry
1240 .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 ago1241 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1242 .on_submit(move |_| Message::Activate(id));
1243 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1244 let width = match width_request(n) {
1245 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1246 _ => Length::Fill,
1247 };
1248 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1249 }
1250 "separator" => widget::divider::horizontal::default().into(),
1251 "spacer" => {
1252 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1253 if n.str("expand").is_empty() {
1254 widget::Space::new().width(size).height(size).into()
1255 } else {
1256 widget::Space::new().width(Length::Fill).height(size).into()
1257 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1258 }
1259 "progress" => {
1260 let bar =
1261 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1262 if n.label().is_empty() {
1263 bar.into()
1264 } else {
1265 Column::new()
1266 .spacing(4)
1267 .push(widget::text::caption(n.label()))
1268 .push(bar)
1269 .into()
1270 }
1271 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1272 // The one node that is not painted where it stands. libcosmic puts a
1273 // dialog up itself, centred over the window and dimming what is
1274 // behind it — `Application::dialog` is the hook, and it is asked for
1275 // one separately from `view`. So the tree carries the dialog wherever
1276 // the client found it convenient to write it, `App::dialog` goes and
1277 // finds it there, and this leaves nothing behind in the layout. A
1278 // node rendered in both places would be painted twice.
1279 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1280 // Kept rather than refused, as in libvidya: a tag this backend has not
1281 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1282 _ => Column::with_children(children(false))
1283 .spacing(spacing)
1284 .padding(margins(n))
1285 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1286 };
1287
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1288 // The containers and the entry size themselves above; anything else asked
1289 // for a width gets it from a wrapper.
1290 match (n.tag.as_str(), width_request(n)) {
1291 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1292 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1293 }
1294}
1295
1296// --- the C ABI: the loop -------------------------------------------------------
1297
1298static TITLE: Mutex<String> = Mutex::new(String::new());
1299
1300/// The window's title, read when `cosmic_run` opens it. A call of its own
1301/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1302/// `cosmic_run` has to be one.
1303///
1304/// # Safety
1305/// `title` is null or a NUL-terminated string.
1306#[no_mangle]
1307pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1308 let title = borrowed(title);
1309 guard((), || *lock(&TITLE) = title)
1310}
1311
1312/// Open the window and run libcosmic until it closes. Blocks; call it on the
1313/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1314///
1315/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1316/// in this process — winit's event loop cannot be made twice.
1317#[no_mangle]
1318pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1319 let status = guard(1, || {
1320 let title = lock(&TITLE).clone();
1321 if RAN.swap(true, SeqCst) {
1322 log::error!("jolt-cosmic: a window already ran in this process");
1323 return 2;
1324 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1325 // The size asked for, until libcosmic reports the one it got.
1326 WINDOW_W.store(width.max(1) as u32, SeqCst);
1327 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1328 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1329 let mut settings = cosmic::app::Settings::default().size(size);
1330 match mode {
1331 1 => settings = settings.theme(cosmic::Theme::dark()),
1332 2 => settings = settings.theme(cosmic::Theme::light()),
1333 _ => {}
1334 }
1335 match cosmic::app::run::<App>(settings, title) {
1336 Ok(()) => 0,
1337 Err(err) => {
1338 eprintln!("jolt-cosmic: {err}");
1339 1
1340 }
1341 }
1342 });
1343 // Outside the guard, so a panic in libcosmic still releases the worker.
1344 *lock(&TO_APP) = None;
1345 CLOSED.store(true, SeqCst);
1346 BELL.notify_all();
1347 status
1348}
1349
1350/// 1 once `cosmic_run` has returned.
1351#[no_mangle]
1352pub extern "C" fn cosmic_should_close() -> c_int {
1353 c_int::from(CLOSED.load(SeqCst))
1354}
1355
1356/// Close the window. Asked before the window exists, it closes on opening.
1357#[no_mangle]
1358pub extern "C" fn cosmic_quit() {
1359 guard((), || {
1360 QUIT_ASKED.store(true, SeqCst);
1361 tell_app(Wake::Quit);
1362 })
1363}
1364
1365/// Publish the edits since the last commit. Answers 1 when there were any.
1366#[no_mangle]
1367pub extern "C" fn cosmic_tree_commit() -> c_int {
1368 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1369 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1370 let snapshot = {
1371 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1372 // A pass that only settled events still publishes, so a control
1373 // holding typed text over an older commit lets go of it.
1374 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1375 return 0;
1376 }
1377 e.dirty = false;
1378 Arc::new(e.tree.clone())
1379 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1380 {
1381 let mut committed = lock(&COMMITTED);
1382 *committed = snapshot;
1383 COMMITTED_SETTLED.store(settled, SeqCst);
1384 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1385 tell_app(Wake::Tree);
1386 1
1387 })
1388}
1389
1390/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1391/// closing. Answers 1 when an event is waiting.
1392#[no_mangle]
1393pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1394 guard(0, || {
1395 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1396 let (mut inbox, _) = BELL
1397 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1398 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1399 })
1400 .unwrap_or_else(|poisoned| poisoned.into_inner());
1401 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1402 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1403 c_int::from(!inbox.queue.is_empty())
1404 })
1405}
1406
1407/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1408#[no_mangle]
1409pub extern "C" fn cosmic_wake() {
1410 guard((), || {
1411 lock(&INBOX).woken = true;
1412 BELL.notify_all();
1413 })
1414}
1415
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1416// --- the C ABI: the window and the desktop -----------------------------------------
1417
1418/// The window's width in points; the size asked for until it has opened.
1419#[no_mangle]
1420pub extern "C" fn cosmic_window_width() -> c_int {
1421 WINDOW_W.load(SeqCst) as c_int
1422}
1423
1424#[no_mangle]
1425pub extern "C" fn cosmic_window_height() -> c_int {
1426 WINDOW_H.load(SeqCst) as c_int
1427}
1428
1429/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1430/// there is no window to ask from; the choice arrives through
1431/// `cosmic_picked_image`.
1432#[no_mangle]
1433pub extern "C" fn cosmic_pick_image() -> c_int {
1434 guard(0, || {
1435 if lock(&TO_APP).is_none() {
1436 return 0;
1437 }
1438 *lock(&PICK) = Pick::Open;
1439 tell_app(Wake::PickImage);
1440 1
1441 })
1442}
1443
1444/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1445/// was chosen since the last call; 0 while the chooser is open, after it was
1446/// cancelled, or when the picture could not be read.
1447///
1448/// # Safety
1449/// `path` is null or a NUL-terminated string.
1450#[no_mangle]
1451pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1452 let path = borrowed(path);
1453 guard(0, || {
1454 let chosen = {
1455 let mut pick = lock(&PICK);
1456 match std::mem::replace(&mut *pick, Pick::Idle) {
1457 Pick::Chosen(chosen) => chosen,
1458 other => {
1459 *pick = other;
1460 return 0;
1461 }
1462 }
1463 };
1464 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1465 Ok(()) => 1,
1466 Err(err) => {
1467 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1468 0
1469 }
1470 }
1471 })
1472}
1473
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1474/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1475/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1476/// already taken, or when the file could not be written.
1477///
1478/// # Safety
1479/// `path` is null or a NUL-terminated string.
1480#[no_mangle]
1481pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1482 let path = borrowed(path);
1483 guard(0, || {
1484 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1485 return 0;
1486 };
1487 match std::fs::write(&*path, png) {
1488 Ok(()) => 1,
1489 Err(err) => {
1490 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1491 0
1492 }
1493 }
1494 })
1495}
1496
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1497// --- the C ABI: events -----------------------------------------------------------
1498
1499static EVENT_NAME: Scratch = Scratch::new();
1500static EVENT_TEXT: Scratch = Scratch::new();
1501
1502/// Dequeue one event; 1 while there was one. The accessors describe it.
1503#[no_mangle]
1504pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1505 guard(0, || {
1506 let mut inbox = lock(&INBOX);
1507 let next = inbox.queue.pop_front();
1508 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1509 if let Some(e) = &next {
1510 inbox.taken = e.seq;
1511 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1512 inbox.current = next;
1513 c_int::from(got)
1514 })
1515}
1516
1517#[no_mangle]
1518pub extern "C" fn cosmic_tree_event_node() -> c_int {
1519 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1520}
1521
1522#[no_mangle]
1523pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1524 guard(empty_str(), || {
1525 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1526 })
1527}
1528
1529#[no_mangle]
1530pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1531 guard(empty_str(), || {
1532 let text = lock(&INBOX)
1533 .current
1534 .as_ref()
1535 .map(|e| e.text.clone())
1536 .unwrap_or_default();
1537 EVENT_TEXT.lend(text)
1538 })
1539}
1540
1541#[no_mangle]
1542pub extern "C" fn cosmic_tree_event_num() -> f64 {
1543 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1544}
1545
1546// --- the C ABI: nodes --------------------------------------------------------------
1547
1548static PROPS: Scratch = Scratch::new();
1549static DUMP: Scratch = Scratch::new();
1550
1551#[no_mangle]
1552pub extern "C" fn cosmic_tree_root() -> c_int {
1553 guard(0, || edit(Tree::root))
1554}
1555
1556/// # Safety
1557/// `tag` is null or a NUL-terminated string.
1558#[no_mangle]
1559pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1560 let tag = borrowed(tag);
1561 guard(0, || edit(|t| t.new_node(&tag)))
1562}
1563
1564#[no_mangle]
1565pub extern "C" fn cosmic_node_free(node: c_int) {
1566 guard((), || edit(|t| t.free(node)))
1567}
1568
1569#[no_mangle]
1570pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1571 guard(0, || c_int::from(read(|t| t.exists(node))))
1572}
1573
1574/// # Safety
1575/// `key` and `value` are null or NUL-terminated strings.
1576#[no_mangle]
1577pub unsafe extern "C" fn cosmic_node_set_str(
1578 node: c_int,
1579 key: *const c_char,
1580 value: *const c_char,
1581) {
1582 let (key, value) = (borrowed(key), borrowed(value));
1583 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1584}
1585
1586/// # Safety
1587/// `key` is null or a NUL-terminated string.
1588#[no_mangle]
1589pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1590 let key = borrowed(key);
1591 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1592}
1593
1594/// # Safety
1595/// `key` is null or a NUL-terminated string.
1596#[no_mangle]
1597pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1598 let key = borrowed(key);
1599 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1600}
1601
1602#[no_mangle]
1603pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1604 guard((), || edit(|t| t.clear_props(node)))
1605}
1606
1607#[no_mangle]
1608pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1609 guard(empty_str(), || {
1610 PROPS.lend(read(|t| {
1611 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1612 }))
1613 })
1614}
1615
1616#[no_mangle]
1617pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1618 guard(0, || {
1619 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1620 })
1621}
1622
1623#[no_mangle]
1624pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1625 guard(0, || {
1626 read(|t| {
1627 t.get(node)
1628 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1629 .unwrap_or(0)
1630 })
1631 })
1632}
1633
1634#[no_mangle]
1635pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1636 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1637}
1638
1639/// Unparents AND frees `child` with everything under it.
1640#[no_mangle]
1641pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1642 guard((), || edit(|t| t.remove(parent, child)))
1643}
1644
1645#[no_mangle]
1646pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1647 guard(0, || {
1648 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1649 })
1650}
1651
1652#[no_mangle]
1653pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1654 guard(0, || {
1655 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1656 })
1657}
1658
1659/// The subtree at `node` as hiccup; 0 is the root.
1660#[no_mangle]
1661pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1662 guard(empty_str(), || {
1663 DUMP.lend(read(|t| {
1664 let id = if node == 0 { t.root_id() } else { node };
1665 t.dump(id)
1666 }))
1667 })
1668}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1669
1670#[cfg(test)]
1671mod tests {
1672 use super::*;
1673
1674 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1675 let id = t.new_node(tag);
1676 assert!(t.append(parent, id));
1677 id
1678 }
1679
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1680 #[test]
1681 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1682 let mut t = Tree::default();
1683 let root = t.root();
1684 let entry = node(&mut t, root, "entry");
1685 t.set(entry, "text", Prop::Str("a".into()));
1686 let mut tree = Arc::new(t);
1687 let mut typed = HashMap::new();
1688 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1689
1690 // Rendered before the worker saw the "b".
1691 keep_typed(&mut tree, &mut typed, 1);
1692 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1693 assert_eq!(typed.len(), 1);
1694
1695 // Rendered after: the component cleared its draft, and that stands.
1696 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1697 keep_typed(&mut tree, &mut typed, 2);
1698 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1699 assert!(typed.is_empty());
1700 }
1701
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1702 #[test]
1703 fn a_zero_width_request_is_no_request() {
1704 let mut t = Tree::default();
1705 let root = t.root();
1706 let column = node(&mut t, root, "vbox");
1707 t.set(column, "width-request", Prop::Num(0.0));
1708 assert_eq!(width_request(t.get(column).unwrap()), None);
1709 t.set(column, "width-request", Prop::Num(260.0));
1710 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1711 }
1712
1713 #[test]
1714 fn a_scroll_is_named_by_its_scroll_key() {
1715 let mut t = Tree::default();
1716 let root = t.root();
1717 let list = node(&mut t, root, "scroll");
1718 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1719 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1720 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1721 }
1722
1723 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1724 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1725 // A row halfway down a long backlog, in a 600pt viewport: half the
1726 // viewport above it, less half the row.
1727 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1728 // The same row with no viewport reported yet: its own top.
1729 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1730 // A row near the top cannot be centred without scrolling above the
1731 // content, and nothing is above the content.
1732 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1733 // A row taller than the viewport is shown from its own top: there is
1734 // no middle of it to put in the middle.
1735 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1736 }
1737
1738 #[test]
1739 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1740 let mut t = Tree::default();
1741 let root = t.root();
1742 let list = node(&mut t, root, "scroll");
1743 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1744 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1745 let before = t.clone();
1746 t.set(rows[3], "scroll-here", Prop::Bool(true));
1747
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1748 // The row, since a row is what has a place written down for it.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1749 let asks = scroll_asks(&before, &t);
1750 assert_eq!(asks.len(), 1);
1751 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1752 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1753
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1754 // And it goes on asking while the row is still asking: the row may not
1755 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1756 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1757 assert_eq!(again[0].reveal, Some(rows[3]));
1758
1759 // A node asking from deeper inside a row answers with the row.
1760 t.set(rows[3], "scroll-here", Prop::Bool(false));
1761 let inner = node(&mut t, rows[1], "vbox");
1762 t.set(inner, "scroll-here", Prop::Bool(true));
1763 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1764
1765 // Nothing asking, nothing to reveal.
1766 t.set(inner, "scroll-here", Prop::Bool(false));
1767 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1768 }
1769
1770 #[test]
1771 fn a_jump_is_the_counter_moving() {
1772 let mut t = Tree::default();
1773 let root = t.root();
1774 let list = node(&mut t, root, "scroll");
1775 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1776 let before = t.clone();
1777 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1778 let asks = scroll_asks(&before, &t);
1779 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1780 }
1781}