nandi/jolt-nativepublic Fork 0
4f920afa410da72bfdb7a07d7faa0264c7bb8a08
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 · 1952 lines · 75.2 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
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago22mod rows;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d 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 9d 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 9d ago31use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
32use std::time::Duration;
33
34use cosmic::app::{Core, Task};
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago35use cosmic::iced::alignment::Horizontal;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago36use cosmic::iced::futures::channel::mpsc;
37use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago38use cosmic::iced::widget::container::Style as ContainerStyle;
39use 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 ago40use cosmic::iced::widget::text::Wrapping;
41use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago42use cosmic::widget::{self, Column, Row};
43use cosmic::{ApplicationExt, Element};
44use jolt_abi::{borrowed, empty_str, guard, Scratch};
45
46fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
47 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
48}
49
50// --- the arena ---------------------------------------------------------------
51
52struct Edits {
53 tree: Tree,
54 /// Set by every mutation, cleared by a commit that published it.
55 dirty: bool,
56}
57
58static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
59 Mutex::new(Edits {
60 tree: Tree::default(),
61 dirty: false,
62 })
63});
64
65/// What `view` paints: the tree as of the last commit.
66static 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 ago67/// The inbox's `settled` when that commit was made. Written under
68/// `COMMITTED`'s lock, so the two are read as a pair.
69static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago70
71fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
72 let mut e = lock(&EDITS);
73 e.dirty = true;
74 f(&mut e.tree)
75}
76
77fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
78 f(&lock(&EDITS).tree)
79}
80
81// --- events, towards jolt ----------------------------------------------------
82
83struct Event {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago84 seq: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago85 node: i32,
86 name: &'static str,
87 text: String,
88 num: f64,
89}
90
91struct Inbox {
92 queue: VecDeque<Event>,
93 current: Option<Event>,
94 woken: bool,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago95 /// The sequence number of the last event posted.
96 posted: u64,
97 /// The sequence number of the last event the worker dequeued.
98 taken: u64,
99 /// `taken` as of the worker's last `cosmic_wait`. Every event up to here
100 /// had its handler run on an earlier pass, so whatever it re-rendered is
101 /// in the arena by the next commit.
102 settled: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago103}
104
105static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
106 queue: VecDeque::new(),
107 current: None,
108 woken: false,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago109 posted: 0,
110 taken: 0,
111 settled: 0,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago112});
113static BELL: Condvar = Condvar::new();
114
115fn 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 ago116 post_seq(node, name, text, num);
117}
118
119/// Queue an event for the worker; answers its sequence number.
120fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 {
121 let seq = {
122 let mut inbox = lock(&INBOX);
123 inbox.posted += 1;
124 let seq = inbox.posted;
125 inbox.queue.push_back(Event {
126 seq,
127 node,
128 name,
129 text,
130 num,
131 });
132 seq
133 };
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago134 BELL.notify_all();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago135 seq
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago136}
137
138// --- wakes, towards iced -----------------------------------------------------
139
140enum Wake {
141 Tree,
142 Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago143 PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago144}
145
146static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
147static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
148static RAN: AtomicBool = AtomicBool::new(false);
149static CLOSED: AtomicBool = AtomicBool::new(false);
150
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago151/// The window's size in points, as libcosmic last reported it. A client that
152/// lays columns out by arithmetic — frq sizes its message list against the
153/// people panel beside it — has to be able to ask.
154static WINDOW_W: AtomicU32 = AtomicU32::new(0);
155static WINDOW_H: AtomicU32 = AtomicU32::new(0);
156
157/// Where a picture chooser opened by `cosmic_pick_image` has got to.
158enum Pick {
159 Idle,
160 Open,
161 Chosen(PathBuf),
162}
163
164static PICK: Mutex<Pick> = Mutex::new(Pick::Idle);
165
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago166/// The picture a Ctrl+V found on the clipboard, held until the worker asks for
167/// it with `cosmic_clipboard_image_png`.
168static CLIPBOARD_PNG: Mutex<Option<Vec<u8>>> = Mutex::new(None);
169
170/// The clipboard read as PNG. Only image/png is asked for: every desktop that
171/// puts a picture on a clipboard puts one there as PNG too.
172struct ClipboardPng(Vec<u8>);
173
174impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng {
175 fn allowed() -> std::borrow::Cow<'static, [String]> {
176 std::borrow::Cow::Owned(vec!["image/png".to_owned()])
177 }
178}
179
180impl TryFrom<(Vec<u8>, String)> for ClipboardPng {
181 type Error = ();
182
183 fn try_from((bytes, _mime): (Vec<u8>, String)) -> Result<Self, ()> {
184 if bytes.is_empty() {
185 Err(())
186 } else {
187 Ok(Self(bytes))
188 }
189 }
190}
191
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago192/// Named for emoji rather than left to fallback: the first face with a glyph
193/// for a smiley is often a monochrome one, and the pill then shows an outline.
194const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji");
195
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago196fn tell_app(wake: Wake) {
197 if let Some(tx) = lock(&TO_APP).as_ref() {
198 let _ = tx.unbounded_send(wake);
199 }
200}
201
202/// The subscription's stream. It opens with a `Tree` wake so a commit made
203/// between `init` and the subscription starting is not missed, and repeats a
204/// quit asked for before there was anyone to tell.
205fn wakes() -> impl Stream<Item = Message> {
206 let (tx, rx) = mpsc::unbounded();
207 let _ = tx.unbounded_send(Wake::Tree);
208 if QUIT_ASKED.load(SeqCst) {
209 let _ = tx.unbounded_send(Wake::Quit);
210 }
211 *lock(&TO_APP) = Some(tx);
212 rx.map(|wake| match wake {
213 Wake::Tree => Message::Tree,
214 Wake::Quit => Message::Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago215 Wake::PickImage => Message::PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago216 })
217}
218
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago219// --- scroll areas ------------------------------------------------------------
220
221/// Where a scroll area was left, kept by name rather than on the widget.
222///
223/// iced keeps a scrollable's offset in its widget tree, and a widget that is
224/// unmounted and mounted again starts at the top. glimmer clients unmount
225/// lists all the time — frq's lightbox is a screen, so looking at a picture
226/// takes the backlog away — so the place is remembered here, under the
227/// `scroll-key` the client names the list by, and put back when it returns.
228struct ScrollMemo {
229 /// Whether the reader is at the newest line. A `stick-to-bottom` list
230 /// follows what arrives only while this holds.
231 at_end: bool,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago232 /// What the client was last told about that, and `None` while it has been
233 /// told nothing.
234 ///
235 /// Held apart from `at_end` because the two answer different questions.
236 /// `at_end` is where this list is; `told` is what the client believes,
237 /// and a report is worth making exactly when they differ. Reporting on a
238 /// change in `at_end` alone loses two cases, and both of them end with a
239 /// "jump to present" button over a backlog that is already at its newest
240 /// line. A list mounting says nothing, because the place it opens at is
241 /// the place the memo guessed it would — but the client's belief is about
242 /// the list this one REPLACED, which in frq is another room entirely. And
243 /// a jump says nothing, because the branch that asked for it marked the
244 /// memo on the way past, so the report that came back agreed with it.
245 told: Option<bool>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago246 offset_y: f32,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago247 /// How tall the viewport was when the reader last moved it, which is what
248 /// a jump centres a row in. Zero until they have: a list nobody has
249 /// scrolled has no reported height, and a row put in the middle of a
250 /// viewport of nothing is a row put at the top — which is the right answer
251 /// to give when the height is not known.
252 height: f32,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago253}
254
255/// Two points of slack: a viewport scrolled to its end by a fractional
256/// offset is still at the end.
257const AT_END_SLACK: f32 = 2.0;
258
259fn scroll_name(n: &Node, id: i32) -> String {
260 match n.str("scroll-key") {
261 "" => format!("node-{id}"),
262 key => key.to_owned(),
263 }
264}
265
266fn scroll_id(name: &str) -> widget::Id {
267 widget::Id::new(format!("jolt-scroll-{name}"))
268}
269
270fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
271 if let Some(n) = t.get(id) {
272 f(id, n);
273 for child in &n.children {
274 walk(t, *child, f);
275 }
276 }
277}
278
279/// What a commit asks of one scroll area.
280struct ScrollAsk {
281 name: String,
282 stick: bool,
283 /// The `scroll-to-bottom` counter, and what it was in the tree before.
284 tick: Option<f64>,
285 tick_before: Option<f64>,
286 /// Not in the tree before this commit: mounted, or mounted again.
287 fresh: bool,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago288 /// The row asking to be shown, if one is.
289 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago290}
291
292/// Every scroll area in `now`, and what changed about each since `before`.
293fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
294 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
295 walk(before, before.root_id(), &mut |id, n| {
296 if n.tag == "scroll" {
297 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
298 }
299 });
300
301 let mut asks = Vec::new();
302 walk(now, now.root_id(), &mut |id, n| {
303 if n.tag != "scroll" {
304 return;
305 }
306 let name = scroll_name(n, id);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago307 // The row holding whatever asked to be shown.
308 //
309 // The row, because a row is what `rows::Rows` writes a place down for;
310 // and the row rather than a guess at where it sits, because this used
311 // to answer with its index over the row count. That is a fraction of
312 // the scroll RANGE and not of the content — the two agree only when
313 // the viewport is exactly one row tall — and it took every row for the
314 // same height besides, in a backlog that puts a one-line message next
315 // to a picture. The landing was out by up to a viewport, worst in the
316 // middle of a list.
317 //
318 // While it is asking, not only on the commit the ask arrives. A row
319 // that is not laid out yet has no place written down for it, and the
320 // ask is over in half a second: asking again each commit is what lets
321 // a jump into a conversation the client has only just switched to land
322 // on the frame the rows finally exist.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago323 let mut reveal = None;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago324 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago325 let mut asked = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago326 walk(now, *row, &mut |_, node| {
327 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago328 });
329 if asked {
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago330 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago331 break;
332 }
333 }
334 asks.push(ScrollAsk {
335 fresh: !named_before.contains_key(&name),
336 tick_before: named_before.get(&name).copied().flatten(),
337 tick: n.num("scroll-to-bottom"),
338 stick: n.bool("stick-to-bottom") == Some(true),
339 reveal,
340 name,
341 });
342 });
343 asks
344}
345
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago346/// What a commit asks of one scroll area, decided.
347#[derive(Debug, PartialEq)]
348enum ScrollMove {
349 /// Show this row of it.
350 Reveal(i32),
351 /// Take it to its newest line.
352 End,
353 /// Put it back where the reader left it, in points.
354 Restore(f32),
355 /// Leave it alone.
356 Stay,
357}
358
359/// What to do with one scroll area, and the memo brought up to date.
360///
361/// Split out from `take_tree` because it is the whole of the thinking and none
362/// of the toolkit: everything here is the ask beside what is remembered, so it
363/// can be read — and tested — without a window to put it in.
364fn scroll_move(ask: &ScrollAsk, memo: &mut ScrollMemo) -> ScrollMove {
365 // A list this tree did not have a moment ago is a list the client has
366 // heard nothing about, whatever it heard about the last one under this
367 // name. Forgetting what it was told is what makes the next report happen,
368 // so that what it believes is about the list it is looking at — in frq,
369 // the room it is in rather than the room it came from.
370 if ask.fresh {
371 memo.told = None;
372 }
373 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
374 // A row is asking to be shown. Whether or not it can be shown yet,
375 // nothing else may move this list while it is asking: the branch below
376 // would otherwise take a reader who was at the newest line — which is
377 // most readers, most of the time — straight back to it, and a jump that
378 // ends at the bottom of the room reads as a jump that did nothing.
379 //
380 // Nothing below marks the memo, either. Where a list lands is `report`'s
381 // to hear from the toolkit and pass on; a memo that wrote the answer down
382 // here would agree with the report when it came and keep it from the
383 // client — the list moved, nobody was told, and the client went on
384 // believing whatever it believed before. Which is a "jump to present"
385 // button over a backlog that is already at its newest line.
386 if let Some(row) = ask.reveal {
387 ScrollMove::Reveal(row)
388 } else if jumped || (ask.stick && memo.at_end) {
389 ScrollMove::End
390 } else if ask.fresh {
391 ScrollMove::Restore(memo.offset_y)
392 } else {
393 ScrollMove::Stay
394 }
395}
396
397/// What to tell the client now that this list is at `at_end`, if anything.
398///
399/// Against what it was last told rather than against where the list was a
400/// moment ago. The two are the same answer for a list the reader is moving by
401/// hand, and they part company wherever something else moved it — see `told`.
402fn report(memo: &mut ScrollMemo, at_end: bool) -> Option<&'static str> {
403 (memo.told != Some(at_end)).then(|| {
404 memo.told = Some(at_end);
405 // "end" or "away", the strings libvidya emits: frq's handler compares
406 // against "end".
407 if at_end { "end" } else { "away" }
408 })
409}
410
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago411/// Where the rows of the scroll area called `name` were last laid out.
412///
413/// By name and not by node, because a scroll area outlives the node ids of a
414/// tree that is rebuilt under it — the same list, and the reader's place in
415/// it, is the thing `scroll-key` names.
416fn placements(name: &str) -> rows::Placements {
417 static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
418 LazyLock::new(|| Mutex::new(HashMap::new()));
419 lock(&BOOKS).entry(name.to_owned()).or_default().clone()
420}
421
422/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
423/// of a viewport `viewport` tall.
424///
425/// The middle rather than the top edge: a line answered three days ago is read
426/// with what was said around it, and a jump that pins it to the ceiling shows
427/// only what came after.
428///
429/// Never above the start of the content — a negative offset is not a place —
430/// and the top edge is the answer while the viewport's height is unknown,
431/// which it is until the reader has scrolled the list once. A row centred in a
432/// viewport of nothing is a row at the top, which is the same answer said
433/// twice, but it is worth being the one that is said on purpose.
434fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
435 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
436}
437
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago438/// Whether to say out loud what every jump decided, on stderr.
439///
440/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
441/// and which of them is wrong is not a thing anyone can tell from a window
442/// that scrolled to the wrong place.
443fn scroll_log() -> bool {
444 static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
445 *ON
446}
447
448/// How many frames a reveal keeps trying for.
449///
450/// A row is measured by the layout that draws it, so the frame a jump is asked
451/// on is a frame too early: the places written down are the ones from before
452/// the room changed. Twenty frames is a third of a second at sixty, which is
453/// longer than a screen takes to build and shorter than a reader waits before
454/// deciding nothing happened.
455const REVEAL_TRIES: u8 = 20;
456
457/// The row of the scroll area called `name` that is asking to be shown, as the
458/// tree has it now.
459///
460/// Asked again on every attempt rather than carried, because a row is not the
461/// same node for long. A buffer that takes a line while a jump is landing is
462/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
463/// node 587 now — so a retry holding the old number would look up a place for
464/// a row nobody has, and go on failing until it gave up. Which room a reader
465/// jumped into decided whether it worked, and that is exactly as strange as
466/// it sounds until you see what it depends on.
467fn asking_row(t: &Tree, name: &str) -> Option<i32> {
468 let mut found = None;
469 walk(t, t.root_id(), &mut |id, n| {
470 if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
471 return;
472 }
473 for row in &n.children {
474 let mut asked = false;
475 walk(t, *row, &mut |_, node| {
476 asked |= node.bool("scroll-here") == Some(true);
477 });
478 if asked {
479 found = Some(*row);
480 break;
481 }
482 }
483 });
484 found
485}
486
487/// Ask to be taken to the row of `name` that wants showing — now if its place
488/// is known, and on the next frame if it is not.
489///
490/// A task that is already finished is not a wasted frame: iced takes its
491/// message on the next pass of the loop, which is after this frame has been
492/// laid out — and being laid out is exactly what the row has to have done for
493/// there to be an answer.
494fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
495 match placements(&name).get(row) {
496 Some((top, height)) => {
497 let y = centred_offset(top, height, viewport);
498 if scroll_log() {
499 eprintln!(
500 "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
501 );
502 }
503 iced_scrollable::scroll_to(scroll_id(&name), AbsoluteOffset { x: None, y: Some(y) })
504 }
505 None => {
506 if scroll_log() {
507 eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
508 }
509 Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
510 }
511 }
512}
513
514/// How many rows the scroll area called `name` has, and how many of them are
515/// asking to be shown. For the log alone.
516fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
517 let mut shape = (0, 0);
518 walk(t, t.root_id(), &mut |id, n| {
519 if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
520 return;
521 }
522 shape.0 = n.children.len();
523 for row in &n.children {
524 let mut asked = false;
525 walk(t, *row, &mut |_, node| {
526 asked |= node.bool("scroll-here") == Some(true);
527 });
528 if asked {
529 shape.1 += 1;
530 }
531 }
532 });
533 shape
534}
535
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago536fn snap_to_end(name: &str) -> Task<Message> {
537 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
538}
539
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago540// --- the app -----------------------------------------------------------------
541
542struct App {
543 core: Core,
544 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago545 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago546 /// What the window last wrote back into a control, by node and prop, with
547 /// the sequence number of the event that carried it to the worker.
548 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago549}
550
551#[derive(Clone, Debug)]
552enum Message {
553 Tree,
554 Quit,
555 Click(i32),
556 Toggled(i32, bool),
557 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago558 Paste(i32, String),
559 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago560 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago561 Hover(i32),
562 Unhover(i32),
563 Scrolled(i32, String, Viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago564 /// Show whichever row of this scroll area is asking to be shown, and how
565 /// many more frames to keep trying for. See `reveal`.
566 Reveal(String, u8),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago567 PickImage,
568 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago569}
570
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago571/// Lay what was typed over a commit that has not caught up with it.
572///
573/// libcosmic paints a control from the tree, so a commit rendered before the
574/// worker saw the latest keystroke would put the older text back under the
575/// caret, and the next key would land on that. An entry is let go once a
576/// commit was rendered after its event: from then on the component's own
577/// state is the answer, a draft it cleared included.
578fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) {
579 typed.retain(|&(node, key), (seq, value)| {
580 let Some(n) = tree.get(node) else { return false };
581 if *seq <= settled {
582 return false;
583 }
584 if n.props.get(key) != Some(value) {
585 Arc::make_mut(tree).set(node, key, value.clone());
586 }
587 true
588 });
589}
590
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago591impl App {
592 /// A widget does not own its value: the new state goes into the arena and
593 /// 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 ago594 /// working control, and its next render is what settles it. Then the event
595 /// goes to the worker, and what was written is held over any commit
596 /// rendered before the worker saw it.
597 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 ago598 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 ago599 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
600 let seq = post_seq(node, event, text, num);
601 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago602 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago603
604 /// Take the committed tree, and move every scroll area to where it should
605 /// be now that it has changed.
606 ///
607 /// A snap is relative, so a list snapped to its end stays at its end as
608 /// rows arrive under it, until the reader scrolls away.
609 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago610 let (committed, settled) = {
611 let c = lock(&COMMITTED);
612 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
613 };
614 let before = std::mem::replace(&mut self.tree, committed);
615 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago616 let mut tasks = Vec::new();
617 let mut live = HashSet::new();
618 for ask in scroll_asks(&before, &self.tree) {
619 live.insert(ask.name.clone());
620 let memo = self
621 .scrolls
622 .entry(ask.name.clone())
623 .or_insert(ScrollMemo {
624 at_end: ask.stick,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago625 told: None,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago626 offset_y: 0.0,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago627 height: 0.0,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago628 });
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago629 // A row asking to be shown is measured on the frame it appears,
630 // so the ask stands until the layout has a place for it — see
631 // `reveal`, which asks again rather than giving up.
632 match scroll_move(&ask, memo) {
633 ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)),
634 ScrollMove::End => tasks.push(snap_to_end(&ask.name)),
635 ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to(
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago636 scroll_id(&ask.name),
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago637 AbsoluteOffset { x: None, y: Some(y) },
638 )),
639 ScrollMove::Stay => {}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago640 }
641 }
642 // A list that was never scrolled keeps no memo worth the space; one
643 // that was keeps its place for when it comes back.
644 self.scrolls
645 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
646 Task::batch(tasks)
647 }
648
649 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
650 let y = viewport.absolute_offset().y;
651 let room = viewport.content_bounds().height - viewport.bounds().height;
652 let at_end = room - y <= AT_END_SLACK;
653 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
654 at_end,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago655 told: None,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago656 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago657 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago658 });
659 memo.at_end = at_end;
660 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago661 memo.height = viewport.bounds().height;
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago662 if let Some(place) = report(memo, at_end) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago663 post(node, "change", place.to_owned(), 0.0);
664 }
665 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago666}
667
668impl cosmic::Application for App {
669 type Executor = cosmic::executor::Default;
670 type Flags = String;
671 type Message = Message;
672 const APP_ID: &'static str = "dev.jolt.Glimmer";
673
674 fn core(&self) -> &Core {
675 &self.core
676 }
677
678 fn core_mut(&mut self) -> &mut Core {
679 &mut self.core
680 }
681
682 fn init(core: Core, title: String) -> (Self, Task<Message>) {
683 let mut app = App {
684 core,
685 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago686 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago687 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago688 };
689 // libcosmic's `wayland` feature brings `multi-window` with it, which
690 // makes a window title a per-window thing.
691 app.set_header_title(title.clone());
692 let task = match app.core.main_window_id() {
693 Some(id) => app.set_window_title(title, id),
694 None => Task::none(),
695 };
696 (app, task)
697 }
698
699 fn subscription(&self) -> Subscription<Message> {
700 Subscription::run(wakes)
701 }
702
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago703 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
704 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
705 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
706 }
707
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago708 fn update(&mut self, message: Message) -> Task<Message> {
709 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago710 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago711 Message::Quit => return cosmic::iced::exit(),
712 Message::Click(node) => post(node, "click", String::new(), 0.0),
713 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago714 let num = f64::from(u8::from(on));
715 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago716 }
717 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago718 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
719 }
720 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
721 // clipboard holding a picture has none, so the field comes back as
722 // it was. That is the paste worth reporting: the picture is read
723 // here, where the clipboard is, and `paste-empty` goes to the
724 // worker, which collects it with `cosmic_clipboard_image_png`.
725 Message::Paste(node, text) => {
726 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
727 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
728 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
729 }
730 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
731 }
732 Message::PastedPicture(node, png) => {
733 *lock(&CLIPBOARD_PNG) = png;
734 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago735 }
736 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago737 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
738 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
739 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 8d ago740 // The row was not laid out when the jump was asked for. Look
741 // again, and keep looking for a few frames: a room the reader has
742 // only just been taken to has to be built before its lines have
743 // anywhere to be.
744 Message::Reveal(name, tries) => {
745 let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
746 let place = asking_row(&self.tree, &name)
747 .and_then(|row| placements(&name).get(row));
748 if let Some((top, height)) = place {
Leave the end flag to the thing that can see the end e39a374 nandi 8d ago749 // Nothing written down here either, for the reason the
750 // commit path gives: where this ends up is `scrolled`'s to
751 // 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 8d ago752 let y = centred_offset(top, height, viewport);
753 return iced_scrollable::scroll_to(
754 scroll_id(&name),
755 AbsoluteOffset { x: None, y: Some(y) },
756 );
757 }
758 if scroll_log() {
759 let asking = asking_row(&self.tree, &name);
760 let (rows, here) = scroll_shape(&self.tree, &name);
761 eprintln!(
762 "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
763 {rows} rows, {here} asking to be shown, \
764 {} placed, viewport {viewport}",
765 placements(&name).len()
766 );
767 }
768 // Not landed yet. Keep trying for the whole budget rather
769 // than stopping the moment nothing is asking: a room the
770 // reader has just been taken to is built over several frames,
771 // and one where the rows are not in the tree yet looks exactly
772 // like a jump that is over. It is not over, it is early.
773 if tries > 0 {
774 return Task::future(async move {
775 cosmic::Action::App(Message::Reveal(name, tries - 1))
776 });
777 }
778 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago779 // The desktop's own chooser, through the portal, on libcosmic's
780 // executor: it is a D-Bus round trip, and the window keeps
781 // painting while it is open.
782 Message::PickImage => {
783 return Task::perform(
784 async {
785 rfd::AsyncFileDialog::new()
786 .set_title("Choose a picture")
787 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
788 .pick_file()
789 .await
790 .map(|file| file.path().to_path_buf())
791 },
792 |path| cosmic::Action::App(Message::Picked(path)),
793 );
794 }
795 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago796 }
797 Task::none()
798 }
799
800 fn view(&self) -> Element<'_, Message> {
801 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago802 let root = element(tree, tree.root_id(), true, false);
803 // A dialog that asked not to be modal, put up here rather than handed
804 // to `dialog` below. It is the same widget in the same place — a
805 // `popover` centres it exactly as `cosmic::app` does — and the whole
806 // of the difference is that this one is not told to intercept the
807 // pointer. That matters to anything the pointer opened: a modal
808 // popover hands the window underneath it a cursor that is
809 // `Unavailable`, so a face that opened a dialog on hover never hears
810 // the pointer leave, and what it opened can never close itself.
811 //
812 // The popover is here whether or not there is anything in it, which
813 // `cosmic::app` says of its own in one line and which this learned
814 // the long way: iced keeps a widget's state by where it sits in the
815 // tree, so a wrapper that comes and goes rebuilds everything under
816 // it — and what "everything" holds is the scroll positions. Wrapping
817 // only when a dialog appeared meant resting the pointer on a face
818 // jumped the conversation behind it.
819 let mut popover = widget::popover(root);
820 if let Some(id) = find_dialog(tree, false) {
821 // The dialog reports its own pointer, on the same two events a
822 // face or a pill reports theirs. Without it a dialog the pointer
823 // opened can only be read at arm's length: the client is told the
824 // pointer left what opened it and never told it arrived here, so
825 // the one way to keep it up is not to move — and everything in it
826 // is out of reach.
827 let popup = widget::mouse_area(dialog_of(tree, id))
828 .on_enter(Message::Hover(id))
829 .on_exit(Message::Unhover(id));
830 popover = popover.popup(popup);
831 }
832 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago833 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago834
835 /// The MODAL dialog the tree is carrying, if it is carrying one.
836 ///
837 /// A client says there is one by putting a `dialog` node in the tree and
838 /// says there is not by leaving it out — the same way it says anything
839 /// else. What comes back is libcosmic's own dialog: centred, over a
840 /// dimmed window, and closed by the buttons the client hung on it.
841 ///
842 /// A dialog that says `modal false` does not come back here. This hook is
843 /// the modal one whether the client wants it or not — `cosmic::app` wraps
844 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
845 /// that kind up itself. See `dialog_of`.
846 fn dialog(&self) -> Option<Element<'_, Message>> {
847 let tree = &*self.tree;
848 let id = find_dialog(tree, true)?;
849 Some(dialog_of(tree, id))
850 }
851}
852
853/// The first `dialog` node in the tree whose modality is `modal`.
854///
855/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
856/// not, which is the shape everything else here takes — a prop left out is
857/// the ordinary answer.
858fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
859 let mut found = None;
860 walk(t, t.root_id(), &mut |id, n| {
861 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
862 found = Some(id);
863 }
864 });
865 found
866}
867
868/// One `dialog` node as libcosmic's dialog.
869///
870/// `label` is its heading and `body` the line under it. Children are its
871/// controls, in order, except that a child carrying `slot` "primary" or
872/// "secondary" becomes that action instead — which is where libcosmic puts
873/// the buttons, at the foot and to the right.
874fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
875 let Some(n) = t.get(id) else {
876 return widget::Space::new().width(0).height(0).into();
877 };
878 let mut d = widget::dialog();
879 if !n.label().is_empty() {
880 d = d.title(n.label().to_owned());
881 }
882 if !n.str("body").is_empty() {
883 d = d.body(n.str("body").to_owned());
884 }
885 if let Some(w) = n.num("max-width") {
886 d = d.max_width(w as f32);
887 }
888 for child in &n.children {
889 let Some(c) = t.get(*child) else { continue };
890 let el = element(t, *child, true, false);
891 d = match c.str("slot") {
892 "primary" => d.primary_action(el),
893 "secondary" => d.secondary_action(el),
894 _ => d.control(el),
895 };
896 }
897 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago898}
899
900// --- props into layout -----------------------------------------------------------
901
902/// `margin` all round, with `margin-top` and its siblings overriding a side.
903fn margins(n: &Node) -> Padding {
904 let all = n.num("margin").unwrap_or(0.0) as f32;
905 let side = |key| n.num(key).map_or(all, |v| v as f32);
906 Padding {
907 top: side("margin-top"),
908 right: side("margin-right"),
909 bottom: side("margin-bottom"),
910 left: side("margin-left"),
911 }
912}
913
914/// A width the client asked for. Zero is the client saying "none": frq writes
915/// `:width-request 0` on its message column whenever the people panel is shut,
916/// and taken literally that is a backlog laid out zero points wide.
917fn width_request(n: &Node) -> Option<f32> {
918 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
919}
920
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago921/// `align`, or `default` where it is not set. A column starts its children at
922/// the left. Rows do not ask: `align` on a row is where along the row its
923/// children sit, not how they line up across it, and the row branch of
924/// `element` reads it itself.
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago925fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago926 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago927 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago928 "center" => Alignment::Center,
929 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago930 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago931 }
932}
933
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago934fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
935 cosmic::theme::Container::custom(move |_| ContainerStyle {
936 background: Some(Background::Color(color)),
937 border: Border {
938 radius: radius.into(),
939 ..Border::default()
940 },
941 text_color: Some(Color::WHITE),
942 ..ContainerStyle::default()
943 })
944}
945
946/// A colour for somebody, from their name, so the same person is the same
947/// colour everywhere they appear.
948fn name_colour(name: &str) -> Color {
949 const PALETTE: [(f32, f32, f32); 8] = [
950 (0.83, 0.33, 0.33),
951 (0.85, 0.55, 0.20),
952 (0.62, 0.62, 0.18),
953 (0.30, 0.65, 0.35),
954 (0.20, 0.62, 0.62),
955 (0.30, 0.50, 0.85),
956 (0.55, 0.40, 0.85),
957 (0.80, 0.35, 0.65),
958 ];
959 let hash = name
960 .bytes()
961 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
962 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
963 Color::from_rgb(r, g, b)
964}
965
966/// A picture that answers a click, with the pointer saying so.
967fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
968 if !enabled {
969 return el;
970 }
971 widget::mouse_area(el)
972 .on_press(Message::Click(id))
973 .interaction(cosmic::iced::mouse::Interaction::Pointer)
974 .into()
975}
976
977fn picture(path: &str) -> Option<widget::image::Handle> {
978 (!path.is_empty() && std::path::Path::new(path).exists())
979 .then(|| widget::image::Handle::from_path(path))
980}
981
982// --- the tree into widgets ---------------------------------------------------------
983
984/// One node and everything under it, as widgets.
985///
986/// `enabled` is inherited: an insensitive container takes its whole subtree out
987/// of interaction. `in_row` is whether the parent lays its children out across:
988/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
989/// column in a column takes the width and a column in a row does not take the
990/// row's slack unless it says `fill-height`.
991fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago992 let Some(n) = t.get(id) else {
993 return Column::new().into();
994 };
995 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago996 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago997 // glimmer-jvui's theme spacing, where the client does not say: a list of
998 // cards with nothing between them reads as one slab.
999 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1000 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 ago1001
1002 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1003 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1004 .width(Length::Fill)
1005 .height(Length::Fill)
1006 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1007 // Sizes are set only where something asked for one. iced's rows and
1008 // columns take `Fill` on an axis from any child that fills it, which is
1009 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
1010 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
1011 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1012 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1013 let across = n.str("orientation") == "horizontal";
1014 if across {
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago1015 // `align` on a row is the MAIN axis, which is glimmer's
1016 // meaning and the one the shared screens are written against:
1017 // `:end` lays the children out *from* the right, so the first
1018 // child in the source is the rightmost on screen. Read as a
1019 // cross-axis gravity instead it did nothing visible but sit
1020 // the chips low, and the message heading's pair came out in
1021 // the order ✏️ ↩️ 🙂 hard against the clock rather than the
1022 // other way round against the edge — see `chat/action-chips`.
1023 let from_end = n.str("align") == "end";
1024 let mut row = if from_end {
1025 Row::with_children(children(true).collect::<Vec<_>>().into_iter().rev())
1026 } else {
1027 Row::with_children(children(true))
1028 }
1029 .spacing(spacing)
1030 .padding(margins(n))
1031 // Across the row the children still centre: a chip beside a
1032 // label sitting against the top of it is what that is for.
1033 .align_y(Alignment::Center);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1034 // A row fills the width it is in only when it or something in
1035 // it asks to; otherwise a line of buttons would spread out.
1036 match width_request(n) {
1037 Some(w) => row = row.width(w),
1038 None if fill_height => row = row.width(Length::Fill),
1039 None => {}
1040 }
1041 if fill_height {
1042 row = row.height(Length::Fill);
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago1043 }
1044 if from_end {
1045 // iced has no main-axis alignment on a Row, so the edge is
1046 // a container's doing: it takes the width and puts the row
1047 // against the right of it. Not a leading Fill space, which
1048 // would have halved the slack with a row that already has
1049 // something filling in it — the join box beside its button
1050 // is that row, and the box is meant to take all of it.
1051 return widget::container(row)
1052 .width(Length::Fill)
1053 .align_x(Horizontal::Right)
1054 .into();
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1055 }
1056 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1057 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1058 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1059 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1060 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1061 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1062 match width_request(n) {
1063 Some(w) => column = column.width(w),
1064 None if fill_height || !in_row => column = column.width(Length::Fill),
1065 None => {}
1066 }
1067 if fill_height {
1068 column = column.height(Length::Fill);
1069 }
1070 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1071 }
1072 }
1073 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1074 let column = Column::with_children(children(false))
1075 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1076 .padding(24)
1077 .width(Length::Fill);
1078 let mut inner = widget::container(column).width(Length::Fill);
1079 if let Some(max) = n.num("max-width") {
1080 inner = inner.max_width(max as f32);
1081 }
1082 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1083 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1084 .height(Length::Fill)
1085 .into()
1086 }
1087 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1088 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 ago1089 if n.tag == "frame" && !n.label().is_empty() {
1090 column = column.push(widget::text::heading(n.label()));
1091 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1092 let card = widget::container(column.extend(children(false)))
1093 .padding(12)
1094 .class(cosmic::theme::Container::Card);
1095 match width_request(n) {
1096 Some(w) => card.width(w).into(),
1097 None if !in_row => card.width(Length::Fill).into(),
1098 None => card.into(),
1099 }
1100 }
1101 // Always fills both ways: a viewport that only fills its width asks its
1102 // column for no height, and is given none. The content is held to its
1103 // own height, since iced will not scroll content that fills the axis it
1104 // scrolls along.
1105 "scroll" => {
1106 let name = scroll_name(n, id);
1107 let content = Column::with_children(children(false))
1108 .spacing(spacing)
1109 .width(Length::Fill)
1110 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1111 // Wrapped in the thing that writes down where each row landed, so
1112 // that "take me to this line" has an answer in points — which is
1113 // the only thing a scroll area can be told. See `rows`.
1114 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1115 widget::scrollable(content)
1116 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1117 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1118 .height(Length::Fill)
1119 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1120 .into()
1121 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1122 // Word wrapping that falls back to breaking inside a word: a URL is one
1123 // word, and it otherwise runs straight past the edge of its column.
1124 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
1125 .wrapping(Wrapping::WordOrGlyph)
1126 .into(),
1127 "label" => widget::text::body(n.label())
1128 .wrapping(Wrapping::WordOrGlyph)
1129 .into(),
1130 "title" => widget::text::title3(n.label())
1131 .wrapping(Wrapping::WordOrGlyph)
1132 .into(),
1133 "title-2" => widget::text::title4(n.label())
1134 .wrapping(Wrapping::WordOrGlyph)
1135 .into(),
1136 "dim-label" => widget::text::caption(n.label())
1137 .wrapping(Wrapping::WordOrGlyph)
1138 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1139 "button" => {
1140 let button = match n.str("kind") {
1141 "primary" => widget::button::suggested(n.label()),
1142 "destructive" => widget::button::destructive(n.label()),
1143 _ => widget::button::standard(n.label()),
1144 };
1145 button
1146 .on_press_maybe(enabled.then_some(Message::Click(id)))
1147 .into()
1148 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1149 "link" => widget::button::link(n.label().to_owned())
1150 .on_press_maybe(enabled.then_some(Message::Click(id)))
1151 .into(),
1152 // A dot that says whether the thing is live, and the words beside it.
1153 "status" => {
1154 let colour = if n.bool("live") == Some(true) {
1155 Color::from_rgb(0.30, 0.72, 0.40)
1156 } else {
1157 Color::from_rgb(0.55, 0.55, 0.55)
1158 };
1159 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
1160 Row::new()
1161 .spacing(6)
1162 .align_y(Alignment::Center)
1163 .push(dot)
1164 .push(widget::text::caption(n.label()))
1165 .into()
1166 }
1167 "spinner" => {
1168 let mut row = Row::new()
1169 .spacing(8)
1170 .align_y(Alignment::Center)
1171 .push(widget::progress_bar::indeterminate_circular().size(16.0));
1172 if !n.label().is_empty() {
1173 row = row.push(widget::text::caption(n.label()));
1174 }
1175 row.into()
1176 }
1177 "emoji" => {
1178 let glyph = match n.str("emoji") {
1179 "" => n.label(),
1180 e => e,
1181 };
1182 widget::text(glyph.to_owned())
1183 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1184 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1185 .into()
1186 }
1187 // A round picture, or the initial on a colour from the name: most
1188 // 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 ago1189 //
1190 // And the three things a face is for besides being looked at. It
1191 // painted as a picture and nothing else until now: a client that
1192 // asked a face to answer a click, to report the pointer arriving, or
1193 // to carry a card under it was handed a portrait that did none of
1194 // them — so the profile behind every avatar in the window was
1195 // unreachable, and the hover card written for it never appeared.
1196 // Those are the same three things `reaction` below does, so they are
1197 // done the same way: `mouse_area` for the press and the two edges of
1198 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1199 "avatar" => {
1200 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 ago1201 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1202 Some(handle) => widget::image(handle)
1203 .width(size)
1204 .height(size)
1205 .content_fit(ContentFit::Cover)
1206 .border_radius(size / 2.0)
1207 .into(),
1208 None => {
1209 let initial: String = n
1210 .label()
1211 .trim_start_matches(|c: char| !c.is_alphanumeric())
1212 .chars()
1213 .next()
1214 .map(|c| c.to_uppercase().collect())
1215 .unwrap_or_default();
1216 widget::container(widget::text(initial).size(size * 0.45))
1217 .center(Length::Fixed(size))
1218 .class(filled(name_colour(n.label()), size / 2.0))
1219 .into()
1220 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1221 };
1222 // The hover is reported whether or not the face is enabled: it
1223 // says where the pointer is, which is true of an insensitive
1224 // picture too. The press is not — an insensitive subtree is out
1225 // of interaction, which is what `enabled` means here.
1226 let mut area = widget::mouse_area(face)
1227 .on_enter(Message::Hover(id))
1228 .on_exit(Message::Unhover(id));
1229 if enabled {
1230 area = area
1231 .on_press(Message::Click(id))
1232 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1233 }
1234 if n.children.is_empty() {
1235 area.into()
1236 } else {
1237 widget::tooltip(
1238 area,
1239 Column::with_children(children(false)).spacing(4),
1240 widget::tooltip::Position::Bottom,
1241 )
1242 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1243 }
1244 }
1245 // A pill: an emoji, how many people, and whether you are one of them.
1246 // What the client hangs under it is its hover card, shown while the
1247 // pointer is on the pill.
1248 "reaction" => {
1249 let glyph = match n.str("emoji") {
1250 "" => n.label(),
1251 e => e,
1252 };
1253 let size = n.num("size").unwrap_or(16.0) as f32;
1254 let mut content = Row::new()
1255 .spacing(4)
1256 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1257 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1258 let count = n.num("count").unwrap_or(0.0);
1259 if count > 0.0 {
1260 content = content.push(widget::text::caption(format!("{count}")));
1261 }
1262 let class = if n.bool("mine") == Some(true) {
1263 widget::button::ButtonClass::Suggested
1264 } else {
1265 widget::button::ButtonClass::Standard
1266 };
1267 let pill = widget::button::custom(content)
1268 .padding([2, 8])
1269 .class(class)
1270 .on_press_maybe(enabled.then_some(Message::Click(id)));
1271 let pill = widget::mouse_area(pill)
1272 .on_enter(Message::Hover(id))
1273 .on_exit(Message::Unhover(id));
1274 if n.children.is_empty() {
1275 pill.into()
1276 } else {
1277 widget::tooltip(
1278 pill,
1279 Column::with_children(children(false)).spacing(4),
1280 widget::tooltip::Position::Bottom,
1281 )
1282 .into()
1283 }
1284 }
1285 // One tag for both kinds of picture, as in libvidya. `feed` is live
1286 // pixels pushed under a name, which nothing pushes here yet, so it
1287 // holds the slot the layout gave it.
1288 "image" => {
1289 let max_w = n.num("max-width").map(|v| v as f32);
1290 let max_h = n.num("max-height").map(|v| v as f32);
1291 if !n.str("feed").is_empty() {
1292 let w = max_w.unwrap_or(160.0);
1293 let h = max_h.unwrap_or(w * 0.75);
1294 widget::container(widget::text::caption("video"))
1295 .center_x(Length::Fixed(w))
1296 .center_y(Length::Fixed(h))
1297 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1298 .into()
1299 } else if let Some(handle) = picture(n.str("src")) {
1300 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1301 if n.bool("fit") == Some(true) {
1302 image = image.width(Length::Fill).height(Length::Fill);
1303 } else if let Some(size) = n.num("size") {
1304 image = image.width(size as f32).height(size as f32);
1305 }
1306 let mut bounded = widget::container(image);
1307 if let Some(w) = max_w {
1308 bounded = bounded.max_width(w);
1309 }
1310 if let Some(h) = max_h {
1311 bounded = bounded.max_height(h);
1312 }
1313 clickable(bounded.into(), id, enabled)
1314 } else {
1315 widget::Space::new().width(0).height(0).into()
1316 }
1317 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1318 "checkbutton" => {
1319 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1320 if enabled {
1321 check = check.on_toggle(move |on| Message::Toggled(id, on));
1322 }
1323 check.into()
1324 }
1325 "entry" => {
1326 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1327 if enabled {
1328 entry = entry
1329 .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 ago1330 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1331 .on_submit(move |_| Message::Activate(id));
1332 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1333 let width = match width_request(n) {
1334 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1335 _ => Length::Fill,
1336 };
1337 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1338 }
1339 "separator" => widget::divider::horizontal::default().into(),
1340 "spacer" => {
1341 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1342 if n.str("expand").is_empty() {
1343 widget::Space::new().width(size).height(size).into()
1344 } else {
1345 widget::Space::new().width(Length::Fill).height(size).into()
1346 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1347 }
1348 "progress" => {
1349 let bar =
1350 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1351 if n.label().is_empty() {
1352 bar.into()
1353 } else {
1354 Column::new()
1355 .spacing(4)
1356 .push(widget::text::caption(n.label()))
1357 .push(bar)
1358 .into()
1359 }
1360 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1361 // The one node that is not painted where it stands. libcosmic puts a
1362 // dialog up itself, centred over the window and dimming what is
1363 // behind it — `Application::dialog` is the hook, and it is asked for
1364 // one separately from `view`. So the tree carries the dialog wherever
1365 // the client found it convenient to write it, `App::dialog` goes and
1366 // finds it there, and this leaves nothing behind in the layout. A
1367 // node rendered in both places would be painted twice.
1368 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1369 // Kept rather than refused, as in libvidya: a tag this backend has not
1370 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1371 _ => Column::with_children(children(false))
1372 .spacing(spacing)
1373 .padding(margins(n))
1374 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1375 };
1376
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1377 // The containers and the entry size themselves above; anything else asked
1378 // for a width gets it from a wrapper.
1379 match (n.tag.as_str(), width_request(n)) {
1380 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1381 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1382 }
1383}
1384
1385// --- the C ABI: the loop -------------------------------------------------------
1386
1387static TITLE: Mutex<String> = Mutex::new(String::new());
1388
1389/// The window's title, read when `cosmic_run` opens it. A call of its own
1390/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1391/// `cosmic_run` has to be one.
1392///
1393/// # Safety
1394/// `title` is null or a NUL-terminated string.
1395#[no_mangle]
1396pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1397 let title = borrowed(title);
1398 guard((), || *lock(&TITLE) = title)
1399}
1400
1401/// Open the window and run libcosmic until it closes. Blocks; call it on the
1402/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1403///
1404/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1405/// in this process — winit's event loop cannot be made twice.
1406#[no_mangle]
1407pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1408 let status = guard(1, || {
1409 let title = lock(&TITLE).clone();
1410 if RAN.swap(true, SeqCst) {
1411 log::error!("jolt-cosmic: a window already ran in this process");
1412 return 2;
1413 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1414 // The size asked for, until libcosmic reports the one it got.
1415 WINDOW_W.store(width.max(1) as u32, SeqCst);
1416 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1417 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1418 let mut settings = cosmic::app::Settings::default().size(size);
1419 match mode {
1420 1 => settings = settings.theme(cosmic::Theme::dark()),
1421 2 => settings = settings.theme(cosmic::Theme::light()),
1422 _ => {}
1423 }
1424 match cosmic::app::run::<App>(settings, title) {
1425 Ok(()) => 0,
1426 Err(err) => {
1427 eprintln!("jolt-cosmic: {err}");
1428 1
1429 }
1430 }
1431 });
1432 // Outside the guard, so a panic in libcosmic still releases the worker.
1433 *lock(&TO_APP) = None;
1434 CLOSED.store(true, SeqCst);
1435 BELL.notify_all();
1436 status
1437}
1438
1439/// 1 once `cosmic_run` has returned.
1440#[no_mangle]
1441pub extern "C" fn cosmic_should_close() -> c_int {
1442 c_int::from(CLOSED.load(SeqCst))
1443}
1444
1445/// Close the window. Asked before the window exists, it closes on opening.
1446#[no_mangle]
1447pub extern "C" fn cosmic_quit() {
1448 guard((), || {
1449 QUIT_ASKED.store(true, SeqCst);
1450 tell_app(Wake::Quit);
1451 })
1452}
1453
1454/// Publish the edits since the last commit. Answers 1 when there were any.
1455#[no_mangle]
1456pub extern "C" fn cosmic_tree_commit() -> c_int {
1457 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1458 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1459 let snapshot = {
1460 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1461 // A pass that only settled events still publishes, so a control
1462 // holding typed text over an older commit lets go of it.
1463 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1464 return 0;
1465 }
1466 e.dirty = false;
1467 Arc::new(e.tree.clone())
1468 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1469 {
1470 let mut committed = lock(&COMMITTED);
1471 *committed = snapshot;
1472 COMMITTED_SETTLED.store(settled, SeqCst);
1473 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1474 tell_app(Wake::Tree);
1475 1
1476 })
1477}
1478
1479/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1480/// closing. Answers 1 when an event is waiting.
1481#[no_mangle]
1482pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1483 guard(0, || {
1484 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1485 let (mut inbox, _) = BELL
1486 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1487 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1488 })
1489 .unwrap_or_else(|poisoned| poisoned.into_inner());
1490 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1491 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1492 c_int::from(!inbox.queue.is_empty())
1493 })
1494}
1495
1496/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1497#[no_mangle]
1498pub extern "C" fn cosmic_wake() {
1499 guard((), || {
1500 lock(&INBOX).woken = true;
1501 BELL.notify_all();
1502 })
1503}
1504
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1505// --- the C ABI: the window and the desktop -----------------------------------------
1506
1507/// The window's width in points; the size asked for until it has opened.
1508#[no_mangle]
1509pub extern "C" fn cosmic_window_width() -> c_int {
1510 WINDOW_W.load(SeqCst) as c_int
1511}
1512
1513#[no_mangle]
1514pub extern "C" fn cosmic_window_height() -> c_int {
1515 WINDOW_H.load(SeqCst) as c_int
1516}
1517
1518/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1519/// there is no window to ask from; the choice arrives through
1520/// `cosmic_picked_image`.
1521#[no_mangle]
1522pub extern "C" fn cosmic_pick_image() -> c_int {
1523 guard(0, || {
1524 if lock(&TO_APP).is_none() {
1525 return 0;
1526 }
1527 *lock(&PICK) = Pick::Open;
1528 tell_app(Wake::PickImage);
1529 1
1530 })
1531}
1532
1533/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1534/// was chosen since the last call; 0 while the chooser is open, after it was
1535/// cancelled, or when the picture could not be read.
1536///
1537/// # Safety
1538/// `path` is null or a NUL-terminated string.
1539#[no_mangle]
1540pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1541 let path = borrowed(path);
1542 guard(0, || {
1543 let chosen = {
1544 let mut pick = lock(&PICK);
1545 match std::mem::replace(&mut *pick, Pick::Idle) {
1546 Pick::Chosen(chosen) => chosen,
1547 other => {
1548 *pick = other;
1549 return 0;
1550 }
1551 }
1552 };
1553 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1554 Ok(()) => 1,
1555 Err(err) => {
1556 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1557 0
1558 }
1559 }
1560 })
1561}
1562
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1563/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1564/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1565/// already taken, or when the file could not be written.
1566///
1567/// # Safety
1568/// `path` is null or a NUL-terminated string.
1569#[no_mangle]
1570pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1571 let path = borrowed(path);
1572 guard(0, || {
1573 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1574 return 0;
1575 };
1576 match std::fs::write(&*path, png) {
1577 Ok(()) => 1,
1578 Err(err) => {
1579 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1580 0
1581 }
1582 }
1583 })
1584}
1585
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1586// --- the C ABI: events -----------------------------------------------------------
1587
1588static EVENT_NAME: Scratch = Scratch::new();
1589static EVENT_TEXT: Scratch = Scratch::new();
1590
1591/// Dequeue one event; 1 while there was one. The accessors describe it.
1592#[no_mangle]
1593pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1594 guard(0, || {
1595 let mut inbox = lock(&INBOX);
1596 let next = inbox.queue.pop_front();
1597 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1598 if let Some(e) = &next {
1599 inbox.taken = e.seq;
1600 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1601 inbox.current = next;
1602 c_int::from(got)
1603 })
1604}
1605
1606#[no_mangle]
1607pub extern "C" fn cosmic_tree_event_node() -> c_int {
1608 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1609}
1610
1611#[no_mangle]
1612pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1613 guard(empty_str(), || {
1614 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1615 })
1616}
1617
1618#[no_mangle]
1619pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1620 guard(empty_str(), || {
1621 let text = lock(&INBOX)
1622 .current
1623 .as_ref()
1624 .map(|e| e.text.clone())
1625 .unwrap_or_default();
1626 EVENT_TEXT.lend(text)
1627 })
1628}
1629
1630#[no_mangle]
1631pub extern "C" fn cosmic_tree_event_num() -> f64 {
1632 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1633}
1634
1635// --- the C ABI: nodes --------------------------------------------------------------
1636
1637static PROPS: Scratch = Scratch::new();
1638static DUMP: Scratch = Scratch::new();
1639
1640#[no_mangle]
1641pub extern "C" fn cosmic_tree_root() -> c_int {
1642 guard(0, || edit(Tree::root))
1643}
1644
1645/// # Safety
1646/// `tag` is null or a NUL-terminated string.
1647#[no_mangle]
1648pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1649 let tag = borrowed(tag);
1650 guard(0, || edit(|t| t.new_node(&tag)))
1651}
1652
1653#[no_mangle]
1654pub extern "C" fn cosmic_node_free(node: c_int) {
1655 guard((), || edit(|t| t.free(node)))
1656}
1657
1658#[no_mangle]
1659pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1660 guard(0, || c_int::from(read(|t| t.exists(node))))
1661}
1662
1663/// # Safety
1664/// `key` and `value` are null or NUL-terminated strings.
1665#[no_mangle]
1666pub unsafe extern "C" fn cosmic_node_set_str(
1667 node: c_int,
1668 key: *const c_char,
1669 value: *const c_char,
1670) {
1671 let (key, value) = (borrowed(key), borrowed(value));
1672 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1673}
1674
1675/// # Safety
1676/// `key` is null or a NUL-terminated string.
1677#[no_mangle]
1678pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1679 let key = borrowed(key);
1680 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1681}
1682
1683/// # Safety
1684/// `key` is null or a NUL-terminated string.
1685#[no_mangle]
1686pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1687 let key = borrowed(key);
1688 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1689}
1690
1691#[no_mangle]
1692pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1693 guard((), || edit(|t| t.clear_props(node)))
1694}
1695
1696#[no_mangle]
1697pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1698 guard(empty_str(), || {
1699 PROPS.lend(read(|t| {
1700 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1701 }))
1702 })
1703}
1704
1705#[no_mangle]
1706pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1707 guard(0, || {
1708 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1709 })
1710}
1711
1712#[no_mangle]
1713pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1714 guard(0, || {
1715 read(|t| {
1716 t.get(node)
1717 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1718 .unwrap_or(0)
1719 })
1720 })
1721}
1722
1723#[no_mangle]
1724pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1725 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1726}
1727
1728/// Unparents AND frees `child` with everything under it.
1729#[no_mangle]
1730pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1731 guard((), || edit(|t| t.remove(parent, child)))
1732}
1733
1734#[no_mangle]
1735pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1736 guard(0, || {
1737 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1738 })
1739}
1740
1741#[no_mangle]
1742pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1743 guard(0, || {
1744 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1745 })
1746}
1747
1748/// The subtree at `node` as hiccup; 0 is the root.
1749#[no_mangle]
1750pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1751 guard(empty_str(), || {
1752 DUMP.lend(read(|t| {
1753 let id = if node == 0 { t.root_id() } else { node };
1754 t.dump(id)
1755 }))
1756 })
1757}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1758
1759#[cfg(test)]
1760mod tests {
1761 use super::*;
1762
1763 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1764 let id = t.new_node(tag);
1765 assert!(t.append(parent, id));
1766 id
1767 }
1768
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1769 #[test]
1770 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1771 let mut t = Tree::default();
1772 let root = t.root();
1773 let entry = node(&mut t, root, "entry");
1774 t.set(entry, "text", Prop::Str("a".into()));
1775 let mut tree = Arc::new(t);
1776 let mut typed = HashMap::new();
1777 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1778
1779 // Rendered before the worker saw the "b".
1780 keep_typed(&mut tree, &mut typed, 1);
1781 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1782 assert_eq!(typed.len(), 1);
1783
1784 // Rendered after: the component cleared its draft, and that stands.
1785 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1786 keep_typed(&mut tree, &mut typed, 2);
1787 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1788 assert!(typed.is_empty());
1789 }
1790
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1791 #[test]
1792 fn a_zero_width_request_is_no_request() {
1793 let mut t = Tree::default();
1794 let root = t.root();
1795 let column = node(&mut t, root, "vbox");
1796 t.set(column, "width-request", Prop::Num(0.0));
1797 assert_eq!(width_request(t.get(column).unwrap()), None);
1798 t.set(column, "width-request", Prop::Num(260.0));
1799 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1800 }
1801
1802 #[test]
1803 fn a_scroll_is_named_by_its_scroll_key() {
1804 let mut t = Tree::default();
1805 let root = t.root();
1806 let list = node(&mut t, root, "scroll");
1807 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1808 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1809 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1810 }
1811
1812 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1813 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1814 // A row halfway down a long backlog, in a 600pt viewport: half the
1815 // viewport above it, less half the row.
1816 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1817 // The same row with no viewport reported yet: its own top.
1818 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1819 // A row near the top cannot be centred without scrolling above the
1820 // content, and nothing is above the content.
1821 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1822 // A row taller than the viewport is shown from its own top: there is
1823 // no middle of it to put in the middle.
1824 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1825 }
1826
1827 #[test]
1828 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1829 let mut t = Tree::default();
1830 let root = t.root();
1831 let list = node(&mut t, root, "scroll");
1832 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1833 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1834 let before = t.clone();
1835 t.set(rows[3], "scroll-here", Prop::Bool(true));
1836
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1837 // 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 ago1838 let asks = scroll_asks(&before, &t);
1839 assert_eq!(asks.len(), 1);
1840 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1841 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1842
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1843 // And it goes on asking while the row is still asking: the row may not
1844 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1845 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1846 assert_eq!(again[0].reveal, Some(rows[3]));
1847
1848 // A node asking from deeper inside a row answers with the row.
1849 t.set(rows[3], "scroll-here", Prop::Bool(false));
1850 let inner = node(&mut t, rows[1], "vbox");
1851 t.set(inner, "scroll-here", Prop::Bool(true));
1852 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1853
1854 // Nothing asking, nothing to reveal.
1855 t.set(inner, "scroll-here", Prop::Bool(false));
1856 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1857 }
1858
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago1859 /// An ask for the scroll area called `name`, with nothing going on.
1860 fn ask(name: &str) -> ScrollAsk {
1861 ScrollAsk {
1862 name: name.to_owned(),
1863 stick: true,
1864 tick: Some(0.0),
1865 tick_before: Some(0.0),
1866 fresh: false,
1867 reveal: None,
1868 }
1869 }
1870
1871 fn memo() -> ScrollMemo {
1872 ScrollMemo { at_end: true, told: Some(true), offset_y: 0.0, height: 600.0 }
1873 }
1874
1875 #[test]
1876 fn a_jump_asks_for_the_end_without_saying_it_arrived() {
1877 let mut m = memo();
1878 m.at_end = false;
1879 m.told = Some(false);
1880 let mut a = ask("backlog");
1881 a.tick = Some(1.0);
1882 assert_eq!(scroll_move(&a, &mut m), ScrollMove::End);
1883 // The memo still says what the last report said, so the report that
1884 // comes back from the toolkit is a change, and is passed on. A memo
1885 // that marked itself here would swallow it, and the client would go
1886 // on believing the reader was away from the newest line.
1887 assert!(!m.at_end);
1888 assert_eq!(report(&mut m, true), Some("end"));
1889 assert_eq!(report(&mut m, true), None);
1890 }
1891
1892 #[test]
1893 fn a_list_that_has_just_mounted_says_where_it_is() {
1894 // The reader left this list away from the end, and the client was
1895 // told so. It comes back — another room under the same widget, or the
1896 // same room after the lightbox took the screen — and lands at the end
1897 // because it sticks there. Nothing about `at_end` CHANGED across
1898 // that, and the client still has to hear it: what it believes is
1899 // about the list this one replaced.
1900 let mut m = memo();
1901 m.at_end = true;
1902 m.told = Some(false);
1903 let mut a = ask("backlog");
1904 a.fresh = true;
1905 assert_eq!(scroll_move(&a, &mut m), ScrollMove::End);
1906 assert_eq!(report(&mut m, true), Some("end"));
1907 }
1908
1909 #[test]
1910 fn a_list_nobody_moved_is_not_reported_twice() {
1911 let mut m = memo();
1912 assert_eq!(report(&mut m, true), None);
1913 assert_eq!(report(&mut m, false), Some("away"));
1914 assert_eq!(report(&mut m, false), None);
1915 assert_eq!(report(&mut m, true), Some("end"));
1916 }
1917
1918 #[test]
1919 fn a_row_asking_to_be_shown_beats_the_end() {
1920 let mut m = memo();
1921 let mut a = ask("backlog");
1922 a.reveal = Some(42);
1923 a.tick = Some(1.0);
1924 assert_eq!(scroll_move(&a, &mut m), ScrollMove::Reveal(42));
1925 }
1926
1927 #[test]
1928 fn a_list_coming_back_is_put_where_it_was_left() {
1929 let mut m = memo();
1930 m.at_end = false;
1931 m.offset_y = 512.0;
1932 let mut a = ask("backlog");
1933 a.fresh = true;
1934 assert_eq!(scroll_move(&a, &mut m), ScrollMove::Restore(512.0));
1935 // And it is asked about again, since the client's belief is about
1936 // whatever was under this name before.
1937 assert_eq!(m.told, None);
1938 assert_eq!(report(&mut m, false), Some("away"));
1939 }
1940
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1941 #[test]
1942 fn a_jump_is_the_counter_moving() {
1943 let mut t = Tree::default();
1944 let root = t.root();
1945 let list = node(&mut t, root, "scroll");
1946 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1947 let before = t.clone();
1948 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1949 let asks = scroll_asks(&before, &t);
1950 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1951 }
1952}