nandi/jolt-nativepublic Fork 0
b49f82a39153750d5c1e6c4367cdfa18e8c0481c
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 · 1776 lines · 67.6 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};
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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 9d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 9d 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 9d 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 9d 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 8d 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 9d 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 9d 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 9d 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 9d 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 8d 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 8d 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 8d 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 {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago560 memo.at_end = false;
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago561 tasks.push(reveal(ask.name.clone(), row, memo.height));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago562 } else if jumped || (ask.stick && memo.at_end) {
563 memo.at_end = true;
564 tasks.push(snap_to_end(&ask.name));
565 } else if ask.fresh {
566 tasks.push(iced_scrollable::scroll_to(
567 scroll_id(&ask.name),
568 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
569 ));
570 }
571 }
572 // A list that was never scrolled keeps no memo worth the space; one
573 // that was keeps its place for when it comes back.
574 self.scrolls
575 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
576 Task::batch(tasks)
577 }
578
579 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
580 let y = viewport.absolute_offset().y;
581 let room = viewport.content_bounds().height - viewport.bounds().height;
582 let at_end = room - y <= AT_END_SLACK;
583 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
584 at_end,
585 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago586 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago587 });
588 let was = memo.at_end;
589 memo.at_end = at_end;
590 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago591 memo.height = viewport.bounds().height;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago592 // "end" or "away", the strings libvidya emits: frq's handler compares
593 // against "end".
594 if was != at_end {
595 let place = if at_end { "end" } else { "away" };
596 post(node, "change", place.to_owned(), 0.0);
597 }
598 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago599}
600
601impl cosmic::Application for App {
602 type Executor = cosmic::executor::Default;
603 type Flags = String;
604 type Message = Message;
605 const APP_ID: &'static str = "dev.jolt.Glimmer";
606
607 fn core(&self) -> &Core {
608 &self.core
609 }
610
611 fn core_mut(&mut self) -> &mut Core {
612 &mut self.core
613 }
614
615 fn init(core: Core, title: String) -> (Self, Task<Message>) {
616 let mut app = App {
617 core,
618 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago619 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago620 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago621 };
622 // libcosmic's `wayland` feature brings `multi-window` with it, which
623 // makes a window title a per-window thing.
624 app.set_header_title(title.clone());
625 let task = match app.core.main_window_id() {
626 Some(id) => app.set_window_title(title, id),
627 None => Task::none(),
628 };
629 (app, task)
630 }
631
632 fn subscription(&self) -> Subscription<Message> {
633 Subscription::run(wakes)
634 }
635
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago636 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
637 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
638 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
639 }
640
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago641 fn update(&mut self, message: Message) -> Task<Message> {
642 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago643 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago644 Message::Quit => return cosmic::iced::exit(),
645 Message::Click(node) => post(node, "click", String::new(), 0.0),
646 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago647 let num = f64::from(u8::from(on));
648 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago649 }
650 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago651 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
652 }
653 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
654 // clipboard holding a picture has none, so the field comes back as
655 // it was. That is the paste worth reporting: the picture is read
656 // here, where the clipboard is, and `paste-empty` goes to the
657 // worker, which collects it with `cosmic_clipboard_image_png`.
658 Message::Paste(node, text) => {
659 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
660 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
661 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
662 }
663 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
664 }
665 Message::PastedPicture(node, png) => {
666 *lock(&CLIPBOARD_PNG) = png;
667 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago668 }
669 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago670 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
671 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
672 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 ago673 // The row was not laid out when the jump was asked for. Look
674 // again, and keep looking for a few frames: a room the reader has
675 // only just been taken to has to be built before its lines have
676 // anywhere to be.
677 Message::Reveal(name, tries) => {
678 let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
679 let place = asking_row(&self.tree, &name)
680 .and_then(|row| placements(&name).get(row));
681 if let Some((top, height)) = place {
682 let y = centred_offset(top, height, viewport);
683 if let Some(memo) = self.scrolls.get_mut(&name) {
684 memo.at_end = false;
685 memo.offset_y = y;
686 }
687 return iced_scrollable::scroll_to(
688 scroll_id(&name),
689 AbsoluteOffset { x: None, y: Some(y) },
690 );
691 }
692 if scroll_log() {
693 let asking = asking_row(&self.tree, &name);
694 let (rows, here) = scroll_shape(&self.tree, &name);
695 eprintln!(
696 "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
697 {rows} rows, {here} asking to be shown, \
698 {} placed, viewport {viewport}",
699 placements(&name).len()
700 );
701 }
702 // Not landed yet. Keep trying for the whole budget rather
703 // than stopping the moment nothing is asking: a room the
704 // reader has just been taken to is built over several frames,
705 // and one where the rows are not in the tree yet looks exactly
706 // like a jump that is over. It is not over, it is early.
707 if tries > 0 {
708 return Task::future(async move {
709 cosmic::Action::App(Message::Reveal(name, tries - 1))
710 });
711 }
712 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago713 // The desktop's own chooser, through the portal, on libcosmic's
714 // executor: it is a D-Bus round trip, and the window keeps
715 // painting while it is open.
716 Message::PickImage => {
717 return Task::perform(
718 async {
719 rfd::AsyncFileDialog::new()
720 .set_title("Choose a picture")
721 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
722 .pick_file()
723 .await
724 .map(|file| file.path().to_path_buf())
725 },
726 |path| cosmic::Action::App(Message::Picked(path)),
727 );
728 }
729 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago730 }
731 Task::none()
732 }
733
734 fn view(&self) -> Element<'_, Message> {
735 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago736 let root = element(tree, tree.root_id(), true, false);
737 // A dialog that asked not to be modal, put up here rather than handed
738 // to `dialog` below. It is the same widget in the same place — a
739 // `popover` centres it exactly as `cosmic::app` does — and the whole
740 // of the difference is that this one is not told to intercept the
741 // pointer. That matters to anything the pointer opened: a modal
742 // popover hands the window underneath it a cursor that is
743 // `Unavailable`, so a face that opened a dialog on hover never hears
744 // the pointer leave, and what it opened can never close itself.
745 //
746 // The popover is here whether or not there is anything in it, which
747 // `cosmic::app` says of its own in one line and which this learned
748 // the long way: iced keeps a widget's state by where it sits in the
749 // tree, so a wrapper that comes and goes rebuilds everything under
750 // it — and what "everything" holds is the scroll positions. Wrapping
751 // only when a dialog appeared meant resting the pointer on a face
752 // jumped the conversation behind it.
753 let mut popover = widget::popover(root);
754 if let Some(id) = find_dialog(tree, false) {
755 // The dialog reports its own pointer, on the same two events a
756 // face or a pill reports theirs. Without it a dialog the pointer
757 // opened can only be read at arm's length: the client is told the
758 // pointer left what opened it and never told it arrived here, so
759 // the one way to keep it up is not to move — and everything in it
760 // is out of reach.
761 let popup = widget::mouse_area(dialog_of(tree, id))
762 .on_enter(Message::Hover(id))
763 .on_exit(Message::Unhover(id));
764 popover = popover.popup(popup);
765 }
766 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago767 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago768
769 /// The MODAL dialog the tree is carrying, if it is carrying one.
770 ///
771 /// A client says there is one by putting a `dialog` node in the tree and
772 /// says there is not by leaving it out — the same way it says anything
773 /// else. What comes back is libcosmic's own dialog: centred, over a
774 /// dimmed window, and closed by the buttons the client hung on it.
775 ///
776 /// A dialog that says `modal false` does not come back here. This hook is
777 /// the modal one whether the client wants it or not — `cosmic::app` wraps
778 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
779 /// that kind up itself. See `dialog_of`.
780 fn dialog(&self) -> Option<Element<'_, Message>> {
781 let tree = &*self.tree;
782 let id = find_dialog(tree, true)?;
783 Some(dialog_of(tree, id))
784 }
785}
786
787/// The first `dialog` node in the tree whose modality is `modal`.
788///
789/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
790/// not, which is the shape everything else here takes — a prop left out is
791/// the ordinary answer.
792fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
793 let mut found = None;
794 walk(t, t.root_id(), &mut |id, n| {
795 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
796 found = Some(id);
797 }
798 });
799 found
800}
801
802/// One `dialog` node as libcosmic's dialog.
803///
804/// `label` is its heading and `body` the line under it. Children are its
805/// controls, in order, except that a child carrying `slot` "primary" or
806/// "secondary" becomes that action instead — which is where libcosmic puts
807/// the buttons, at the foot and to the right.
808fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
809 let Some(n) = t.get(id) else {
810 return widget::Space::new().width(0).height(0).into();
811 };
812 let mut d = widget::dialog();
813 if !n.label().is_empty() {
814 d = d.title(n.label().to_owned());
815 }
816 if !n.str("body").is_empty() {
817 d = d.body(n.str("body").to_owned());
818 }
819 if let Some(w) = n.num("max-width") {
820 d = d.max_width(w as f32);
821 }
822 for child in &n.children {
823 let Some(c) = t.get(*child) else { continue };
824 let el = element(t, *child, true, false);
825 d = match c.str("slot") {
826 "primary" => d.primary_action(el),
827 "secondary" => d.secondary_action(el),
828 _ => d.control(el),
829 };
830 }
831 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago832}
833
834// --- props into layout -----------------------------------------------------------
835
836/// `margin` all round, with `margin-top` and its siblings overriding a side.
837fn margins(n: &Node) -> Padding {
838 let all = n.num("margin").unwrap_or(0.0) as f32;
839 let side = |key| n.num(key).map_or(all, |v| v as f32);
840 Padding {
841 top: side("margin-top"),
842 right: side("margin-right"),
843 bottom: side("margin-bottom"),
844 left: side("margin-left"),
845 }
846}
847
848/// A width the client asked for. Zero is the client saying "none": frq writes
849/// `:width-request 0` on its message column whenever the people panel is shut,
850/// and taken literally that is a backlog laid out zero points wide.
851fn width_request(n: &Node) -> Option<f32> {
852 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
853}
854
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago855/// `align`, or `default` where it is not set. A row centres its children on
856/// the cross axis by default — a label beside a button otherwise sits against
857/// the top of the button — and a column starts them at the left.
858fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago859 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago860 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago861 "center" => Alignment::Center,
862 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago863 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago864 }
865}
866
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago867fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
868 cosmic::theme::Container::custom(move |_| ContainerStyle {
869 background: Some(Background::Color(color)),
870 border: Border {
871 radius: radius.into(),
872 ..Border::default()
873 },
874 text_color: Some(Color::WHITE),
875 ..ContainerStyle::default()
876 })
877}
878
879/// A colour for somebody, from their name, so the same person is the same
880/// colour everywhere they appear.
881fn name_colour(name: &str) -> Color {
882 const PALETTE: [(f32, f32, f32); 8] = [
883 (0.83, 0.33, 0.33),
884 (0.85, 0.55, 0.20),
885 (0.62, 0.62, 0.18),
886 (0.30, 0.65, 0.35),
887 (0.20, 0.62, 0.62),
888 (0.30, 0.50, 0.85),
889 (0.55, 0.40, 0.85),
890 (0.80, 0.35, 0.65),
891 ];
892 let hash = name
893 .bytes()
894 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
895 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
896 Color::from_rgb(r, g, b)
897}
898
899/// A picture that answers a click, with the pointer saying so.
900fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
901 if !enabled {
902 return el;
903 }
904 widget::mouse_area(el)
905 .on_press(Message::Click(id))
906 .interaction(cosmic::iced::mouse::Interaction::Pointer)
907 .into()
908}
909
910fn picture(path: &str) -> Option<widget::image::Handle> {
911 (!path.is_empty() && std::path::Path::new(path).exists())
912 .then(|| widget::image::Handle::from_path(path))
913}
914
915// --- the tree into widgets ---------------------------------------------------------
916
917/// One node and everything under it, as widgets.
918///
919/// `enabled` is inherited: an insensitive container takes its whole subtree out
920/// of interaction. `in_row` is whether the parent lays its children out across:
921/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
922/// column in a column takes the width and a column in a row does not take the
923/// row's slack unless it says `fill-height`.
924fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago925 let Some(n) = t.get(id) else {
926 return Column::new().into();
927 };
928 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago929 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago930 // glimmer-jvui's theme spacing, where the client does not say: a list of
931 // cards with nothing between them reads as one slab.
932 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago933 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 ago934
935 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago936 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago937 .width(Length::Fill)
938 .height(Length::Fill)
939 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago940 // Sizes are set only where something asked for one. iced's rows and
941 // columns take `Fill` on an axis from any child that fills it, which is
942 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
943 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
944 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago945 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago946 let across = n.str("orientation") == "horizontal";
947 if across {
948 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago949 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago950 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago951 .align_y(alignment(n, Alignment::Center));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago952 // A row fills the width it is in only when it or something in
953 // it asks to; otherwise a line of buttons would spread out.
954 match width_request(n) {
955 Some(w) => row = row.width(w),
956 None if fill_height => row = row.width(Length::Fill),
957 None => {}
958 }
959 if fill_height {
960 row = row.height(Length::Fill);
961 }
962 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago963 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago964 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago965 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago966 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago967 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago968 match width_request(n) {
969 Some(w) => column = column.width(w),
970 None if fill_height || !in_row => column = column.width(Length::Fill),
971 None => {}
972 }
973 if fill_height {
974 column = column.height(Length::Fill);
975 }
976 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago977 }
978 }
979 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago980 let column = Column::with_children(children(false))
981 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago982 .padding(24)
983 .width(Length::Fill);
984 let mut inner = widget::container(column).width(Length::Fill);
985 if let Some(max) = n.num("max-width") {
986 inner = inner.max_width(max as f32);
987 }
988 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago989 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago990 .height(Length::Fill)
991 .into()
992 }
993 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago994 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 ago995 if n.tag == "frame" && !n.label().is_empty() {
996 column = column.push(widget::text::heading(n.label()));
997 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago998 let card = widget::container(column.extend(children(false)))
999 .padding(12)
1000 .class(cosmic::theme::Container::Card);
1001 match width_request(n) {
1002 Some(w) => card.width(w).into(),
1003 None if !in_row => card.width(Length::Fill).into(),
1004 None => card.into(),
1005 }
1006 }
1007 // Always fills both ways: a viewport that only fills its width asks its
1008 // column for no height, and is given none. The content is held to its
1009 // own height, since iced will not scroll content that fills the axis it
1010 // scrolls along.
1011 "scroll" => {
1012 let name = scroll_name(n, id);
1013 let content = Column::with_children(children(false))
1014 .spacing(spacing)
1015 .width(Length::Fill)
1016 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1017 // Wrapped in the thing that writes down where each row landed, so
1018 // that "take me to this line" has an answer in points — which is
1019 // the only thing a scroll area can be told. See `rows`.
1020 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1021 widget::scrollable(content)
1022 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1023 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1024 .height(Length::Fill)
1025 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1026 .into()
1027 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1028 // Word wrapping that falls back to breaking inside a word: a URL is one
1029 // word, and it otherwise runs straight past the edge of its column.
1030 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
1031 .wrapping(Wrapping::WordOrGlyph)
1032 .into(),
1033 "label" => widget::text::body(n.label())
1034 .wrapping(Wrapping::WordOrGlyph)
1035 .into(),
1036 "title" => widget::text::title3(n.label())
1037 .wrapping(Wrapping::WordOrGlyph)
1038 .into(),
1039 "title-2" => widget::text::title4(n.label())
1040 .wrapping(Wrapping::WordOrGlyph)
1041 .into(),
1042 "dim-label" => widget::text::caption(n.label())
1043 .wrapping(Wrapping::WordOrGlyph)
1044 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1045 "button" => {
1046 let button = match n.str("kind") {
1047 "primary" => widget::button::suggested(n.label()),
1048 "destructive" => widget::button::destructive(n.label()),
1049 _ => widget::button::standard(n.label()),
1050 };
1051 button
1052 .on_press_maybe(enabled.then_some(Message::Click(id)))
1053 .into()
1054 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1055 "link" => widget::button::link(n.label().to_owned())
1056 .on_press_maybe(enabled.then_some(Message::Click(id)))
1057 .into(),
1058 // A dot that says whether the thing is live, and the words beside it.
1059 "status" => {
1060 let colour = if n.bool("live") == Some(true) {
1061 Color::from_rgb(0.30, 0.72, 0.40)
1062 } else {
1063 Color::from_rgb(0.55, 0.55, 0.55)
1064 };
1065 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
1066 Row::new()
1067 .spacing(6)
1068 .align_y(Alignment::Center)
1069 .push(dot)
1070 .push(widget::text::caption(n.label()))
1071 .into()
1072 }
1073 "spinner" => {
1074 let mut row = Row::new()
1075 .spacing(8)
1076 .align_y(Alignment::Center)
1077 .push(widget::progress_bar::indeterminate_circular().size(16.0));
1078 if !n.label().is_empty() {
1079 row = row.push(widget::text::caption(n.label()));
1080 }
1081 row.into()
1082 }
1083 "emoji" => {
1084 let glyph = match n.str("emoji") {
1085 "" => n.label(),
1086 e => e,
1087 };
1088 widget::text(glyph.to_owned())
1089 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1090 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1091 .into()
1092 }
1093 // A round picture, or the initial on a colour from the name: most
1094 // 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 ago1095 //
1096 // And the three things a face is for besides being looked at. It
1097 // painted as a picture and nothing else until now: a client that
1098 // asked a face to answer a click, to report the pointer arriving, or
1099 // to carry a card under it was handed a portrait that did none of
1100 // them — so the profile behind every avatar in the window was
1101 // unreachable, and the hover card written for it never appeared.
1102 // Those are the same three things `reaction` below does, so they are
1103 // done the same way: `mouse_area` for the press and the two edges of
1104 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1105 "avatar" => {
1106 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 ago1107 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1108 Some(handle) => widget::image(handle)
1109 .width(size)
1110 .height(size)
1111 .content_fit(ContentFit::Cover)
1112 .border_radius(size / 2.0)
1113 .into(),
1114 None => {
1115 let initial: String = n
1116 .label()
1117 .trim_start_matches(|c: char| !c.is_alphanumeric())
1118 .chars()
1119 .next()
1120 .map(|c| c.to_uppercase().collect())
1121 .unwrap_or_default();
1122 widget::container(widget::text(initial).size(size * 0.45))
1123 .center(Length::Fixed(size))
1124 .class(filled(name_colour(n.label()), size / 2.0))
1125 .into()
1126 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1127 };
1128 // The hover is reported whether or not the face is enabled: it
1129 // says where the pointer is, which is true of an insensitive
1130 // picture too. The press is not — an insensitive subtree is out
1131 // of interaction, which is what `enabled` means here.
1132 let mut area = widget::mouse_area(face)
1133 .on_enter(Message::Hover(id))
1134 .on_exit(Message::Unhover(id));
1135 if enabled {
1136 area = area
1137 .on_press(Message::Click(id))
1138 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1139 }
1140 if n.children.is_empty() {
1141 area.into()
1142 } else {
1143 widget::tooltip(
1144 area,
1145 Column::with_children(children(false)).spacing(4),
1146 widget::tooltip::Position::Bottom,
1147 )
1148 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1149 }
1150 }
1151 // A pill: an emoji, how many people, and whether you are one of them.
1152 // What the client hangs under it is its hover card, shown while the
1153 // pointer is on the pill.
1154 "reaction" => {
1155 let glyph = match n.str("emoji") {
1156 "" => n.label(),
1157 e => e,
1158 };
1159 let size = n.num("size").unwrap_or(16.0) as f32;
1160 let mut content = Row::new()
1161 .spacing(4)
1162 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1163 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1164 let count = n.num("count").unwrap_or(0.0);
1165 if count > 0.0 {
1166 content = content.push(widget::text::caption(format!("{count}")));
1167 }
1168 let class = if n.bool("mine") == Some(true) {
1169 widget::button::ButtonClass::Suggested
1170 } else {
1171 widget::button::ButtonClass::Standard
1172 };
1173 let pill = widget::button::custom(content)
1174 .padding([2, 8])
1175 .class(class)
1176 .on_press_maybe(enabled.then_some(Message::Click(id)));
1177 let pill = widget::mouse_area(pill)
1178 .on_enter(Message::Hover(id))
1179 .on_exit(Message::Unhover(id));
1180 if n.children.is_empty() {
1181 pill.into()
1182 } else {
1183 widget::tooltip(
1184 pill,
1185 Column::with_children(children(false)).spacing(4),
1186 widget::tooltip::Position::Bottom,
1187 )
1188 .into()
1189 }
1190 }
1191 // One tag for both kinds of picture, as in libvidya. `feed` is live
1192 // pixels pushed under a name, which nothing pushes here yet, so it
1193 // holds the slot the layout gave it.
1194 "image" => {
1195 let max_w = n.num("max-width").map(|v| v as f32);
1196 let max_h = n.num("max-height").map(|v| v as f32);
1197 if !n.str("feed").is_empty() {
1198 let w = max_w.unwrap_or(160.0);
1199 let h = max_h.unwrap_or(w * 0.75);
1200 widget::container(widget::text::caption("video"))
1201 .center_x(Length::Fixed(w))
1202 .center_y(Length::Fixed(h))
1203 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1204 .into()
1205 } else if let Some(handle) = picture(n.str("src")) {
1206 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1207 if n.bool("fit") == Some(true) {
1208 image = image.width(Length::Fill).height(Length::Fill);
1209 } else if let Some(size) = n.num("size") {
1210 image = image.width(size as f32).height(size as f32);
1211 }
1212 let mut bounded = widget::container(image);
1213 if let Some(w) = max_w {
1214 bounded = bounded.max_width(w);
1215 }
1216 if let Some(h) = max_h {
1217 bounded = bounded.max_height(h);
1218 }
1219 clickable(bounded.into(), id, enabled)
1220 } else {
1221 widget::Space::new().width(0).height(0).into()
1222 }
1223 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1224 "checkbutton" => {
1225 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1226 if enabled {
1227 check = check.on_toggle(move |on| Message::Toggled(id, on));
1228 }
1229 check.into()
1230 }
1231 "entry" => {
1232 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1233 if enabled {
1234 entry = entry
1235 .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 ago1236 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1237 .on_submit(move |_| Message::Activate(id));
1238 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1239 let width = match width_request(n) {
1240 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1241 _ => Length::Fill,
1242 };
1243 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1244 }
1245 "separator" => widget::divider::horizontal::default().into(),
1246 "spacer" => {
1247 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1248 if n.str("expand").is_empty() {
1249 widget::Space::new().width(size).height(size).into()
1250 } else {
1251 widget::Space::new().width(Length::Fill).height(size).into()
1252 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1253 }
1254 "progress" => {
1255 let bar =
1256 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1257 if n.label().is_empty() {
1258 bar.into()
1259 } else {
1260 Column::new()
1261 .spacing(4)
1262 .push(widget::text::caption(n.label()))
1263 .push(bar)
1264 .into()
1265 }
1266 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1267 // The one node that is not painted where it stands. libcosmic puts a
1268 // dialog up itself, centred over the window and dimming what is
1269 // behind it — `Application::dialog` is the hook, and it is asked for
1270 // one separately from `view`. So the tree carries the dialog wherever
1271 // the client found it convenient to write it, `App::dialog` goes and
1272 // finds it there, and this leaves nothing behind in the layout. A
1273 // node rendered in both places would be painted twice.
1274 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1275 // Kept rather than refused, as in libvidya: a tag this backend has not
1276 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1277 _ => Column::with_children(children(false))
1278 .spacing(spacing)
1279 .padding(margins(n))
1280 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1281 };
1282
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1283 // The containers and the entry size themselves above; anything else asked
1284 // for a width gets it from a wrapper.
1285 match (n.tag.as_str(), width_request(n)) {
1286 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1287 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1288 }
1289}
1290
1291// --- the C ABI: the loop -------------------------------------------------------
1292
1293static TITLE: Mutex<String> = Mutex::new(String::new());
1294
1295/// The window's title, read when `cosmic_run` opens it. A call of its own
1296/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1297/// `cosmic_run` has to be one.
1298///
1299/// # Safety
1300/// `title` is null or a NUL-terminated string.
1301#[no_mangle]
1302pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1303 let title = borrowed(title);
1304 guard((), || *lock(&TITLE) = title)
1305}
1306
1307/// Open the window and run libcosmic until it closes. Blocks; call it on the
1308/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1309///
1310/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1311/// in this process — winit's event loop cannot be made twice.
1312#[no_mangle]
1313pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1314 let status = guard(1, || {
1315 let title = lock(&TITLE).clone();
1316 if RAN.swap(true, SeqCst) {
1317 log::error!("jolt-cosmic: a window already ran in this process");
1318 return 2;
1319 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1320 // The size asked for, until libcosmic reports the one it got.
1321 WINDOW_W.store(width.max(1) as u32, SeqCst);
1322 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1323 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1324 let mut settings = cosmic::app::Settings::default().size(size);
1325 match mode {
1326 1 => settings = settings.theme(cosmic::Theme::dark()),
1327 2 => settings = settings.theme(cosmic::Theme::light()),
1328 _ => {}
1329 }
1330 match cosmic::app::run::<App>(settings, title) {
1331 Ok(()) => 0,
1332 Err(err) => {
1333 eprintln!("jolt-cosmic: {err}");
1334 1
1335 }
1336 }
1337 });
1338 // Outside the guard, so a panic in libcosmic still releases the worker.
1339 *lock(&TO_APP) = None;
1340 CLOSED.store(true, SeqCst);
1341 BELL.notify_all();
1342 status
1343}
1344
1345/// 1 once `cosmic_run` has returned.
1346#[no_mangle]
1347pub extern "C" fn cosmic_should_close() -> c_int {
1348 c_int::from(CLOSED.load(SeqCst))
1349}
1350
1351/// Close the window. Asked before the window exists, it closes on opening.
1352#[no_mangle]
1353pub extern "C" fn cosmic_quit() {
1354 guard((), || {
1355 QUIT_ASKED.store(true, SeqCst);
1356 tell_app(Wake::Quit);
1357 })
1358}
1359
1360/// Publish the edits since the last commit. Answers 1 when there were any.
1361#[no_mangle]
1362pub extern "C" fn cosmic_tree_commit() -> c_int {
1363 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1364 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1365 let snapshot = {
1366 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1367 // A pass that only settled events still publishes, so a control
1368 // holding typed text over an older commit lets go of it.
1369 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1370 return 0;
1371 }
1372 e.dirty = false;
1373 Arc::new(e.tree.clone())
1374 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1375 {
1376 let mut committed = lock(&COMMITTED);
1377 *committed = snapshot;
1378 COMMITTED_SETTLED.store(settled, SeqCst);
1379 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1380 tell_app(Wake::Tree);
1381 1
1382 })
1383}
1384
1385/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1386/// closing. Answers 1 when an event is waiting.
1387#[no_mangle]
1388pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1389 guard(0, || {
1390 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1391 let (mut inbox, _) = BELL
1392 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1393 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1394 })
1395 .unwrap_or_else(|poisoned| poisoned.into_inner());
1396 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1397 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1398 c_int::from(!inbox.queue.is_empty())
1399 })
1400}
1401
1402/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1403#[no_mangle]
1404pub extern "C" fn cosmic_wake() {
1405 guard((), || {
1406 lock(&INBOX).woken = true;
1407 BELL.notify_all();
1408 })
1409}
1410
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1411// --- the C ABI: the window and the desktop -----------------------------------------
1412
1413/// The window's width in points; the size asked for until it has opened.
1414#[no_mangle]
1415pub extern "C" fn cosmic_window_width() -> c_int {
1416 WINDOW_W.load(SeqCst) as c_int
1417}
1418
1419#[no_mangle]
1420pub extern "C" fn cosmic_window_height() -> c_int {
1421 WINDOW_H.load(SeqCst) as c_int
1422}
1423
1424/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1425/// there is no window to ask from; the choice arrives through
1426/// `cosmic_picked_image`.
1427#[no_mangle]
1428pub extern "C" fn cosmic_pick_image() -> c_int {
1429 guard(0, || {
1430 if lock(&TO_APP).is_none() {
1431 return 0;
1432 }
1433 *lock(&PICK) = Pick::Open;
1434 tell_app(Wake::PickImage);
1435 1
1436 })
1437}
1438
1439/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1440/// was chosen since the last call; 0 while the chooser is open, after it was
1441/// cancelled, or when the picture could not be read.
1442///
1443/// # Safety
1444/// `path` is null or a NUL-terminated string.
1445#[no_mangle]
1446pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1447 let path = borrowed(path);
1448 guard(0, || {
1449 let chosen = {
1450 let mut pick = lock(&PICK);
1451 match std::mem::replace(&mut *pick, Pick::Idle) {
1452 Pick::Chosen(chosen) => chosen,
1453 other => {
1454 *pick = other;
1455 return 0;
1456 }
1457 }
1458 };
1459 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1460 Ok(()) => 1,
1461 Err(err) => {
1462 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1463 0
1464 }
1465 }
1466 })
1467}
1468
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1469/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1470/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1471/// already taken, or when the file could not be written.
1472///
1473/// # Safety
1474/// `path` is null or a NUL-terminated string.
1475#[no_mangle]
1476pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1477 let path = borrowed(path);
1478 guard(0, || {
1479 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1480 return 0;
1481 };
1482 match std::fs::write(&*path, png) {
1483 Ok(()) => 1,
1484 Err(err) => {
1485 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1486 0
1487 }
1488 }
1489 })
1490}
1491
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1492// --- the C ABI: events -----------------------------------------------------------
1493
1494static EVENT_NAME: Scratch = Scratch::new();
1495static EVENT_TEXT: Scratch = Scratch::new();
1496
1497/// Dequeue one event; 1 while there was one. The accessors describe it.
1498#[no_mangle]
1499pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1500 guard(0, || {
1501 let mut inbox = lock(&INBOX);
1502 let next = inbox.queue.pop_front();
1503 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1504 if let Some(e) = &next {
1505 inbox.taken = e.seq;
1506 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1507 inbox.current = next;
1508 c_int::from(got)
1509 })
1510}
1511
1512#[no_mangle]
1513pub extern "C" fn cosmic_tree_event_node() -> c_int {
1514 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1515}
1516
1517#[no_mangle]
1518pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1519 guard(empty_str(), || {
1520 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1521 })
1522}
1523
1524#[no_mangle]
1525pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1526 guard(empty_str(), || {
1527 let text = lock(&INBOX)
1528 .current
1529 .as_ref()
1530 .map(|e| e.text.clone())
1531 .unwrap_or_default();
1532 EVENT_TEXT.lend(text)
1533 })
1534}
1535
1536#[no_mangle]
1537pub extern "C" fn cosmic_tree_event_num() -> f64 {
1538 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1539}
1540
1541// --- the C ABI: nodes --------------------------------------------------------------
1542
1543static PROPS: Scratch = Scratch::new();
1544static DUMP: Scratch = Scratch::new();
1545
1546#[no_mangle]
1547pub extern "C" fn cosmic_tree_root() -> c_int {
1548 guard(0, || edit(Tree::root))
1549}
1550
1551/// # Safety
1552/// `tag` is null or a NUL-terminated string.
1553#[no_mangle]
1554pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1555 let tag = borrowed(tag);
1556 guard(0, || edit(|t| t.new_node(&tag)))
1557}
1558
1559#[no_mangle]
1560pub extern "C" fn cosmic_node_free(node: c_int) {
1561 guard((), || edit(|t| t.free(node)))
1562}
1563
1564#[no_mangle]
1565pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1566 guard(0, || c_int::from(read(|t| t.exists(node))))
1567}
1568
1569/// # Safety
1570/// `key` and `value` are null or NUL-terminated strings.
1571#[no_mangle]
1572pub unsafe extern "C" fn cosmic_node_set_str(
1573 node: c_int,
1574 key: *const c_char,
1575 value: *const c_char,
1576) {
1577 let (key, value) = (borrowed(key), borrowed(value));
1578 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1579}
1580
1581/// # Safety
1582/// `key` is null or a NUL-terminated string.
1583#[no_mangle]
1584pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1585 let key = borrowed(key);
1586 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1587}
1588
1589/// # Safety
1590/// `key` is null or a NUL-terminated string.
1591#[no_mangle]
1592pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1593 let key = borrowed(key);
1594 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1595}
1596
1597#[no_mangle]
1598pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1599 guard((), || edit(|t| t.clear_props(node)))
1600}
1601
1602#[no_mangle]
1603pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1604 guard(empty_str(), || {
1605 PROPS.lend(read(|t| {
1606 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1607 }))
1608 })
1609}
1610
1611#[no_mangle]
1612pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1613 guard(0, || {
1614 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1615 })
1616}
1617
1618#[no_mangle]
1619pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1620 guard(0, || {
1621 read(|t| {
1622 t.get(node)
1623 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1624 .unwrap_or(0)
1625 })
1626 })
1627}
1628
1629#[no_mangle]
1630pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1631 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1632}
1633
1634/// Unparents AND frees `child` with everything under it.
1635#[no_mangle]
1636pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1637 guard((), || edit(|t| t.remove(parent, child)))
1638}
1639
1640#[no_mangle]
1641pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1642 guard(0, || {
1643 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1644 })
1645}
1646
1647#[no_mangle]
1648pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1649 guard(0, || {
1650 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1651 })
1652}
1653
1654/// The subtree at `node` as hiccup; 0 is the root.
1655#[no_mangle]
1656pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1657 guard(empty_str(), || {
1658 DUMP.lend(read(|t| {
1659 let id = if node == 0 { t.root_id() } else { node };
1660 t.dump(id)
1661 }))
1662 })
1663}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1664
1665#[cfg(test)]
1666mod tests {
1667 use super::*;
1668
1669 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1670 let id = t.new_node(tag);
1671 assert!(t.append(parent, id));
1672 id
1673 }
1674
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1675 #[test]
1676 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1677 let mut t = Tree::default();
1678 let root = t.root();
1679 let entry = node(&mut t, root, "entry");
1680 t.set(entry, "text", Prop::Str("a".into()));
1681 let mut tree = Arc::new(t);
1682 let mut typed = HashMap::new();
1683 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1684
1685 // Rendered before the worker saw the "b".
1686 keep_typed(&mut tree, &mut typed, 1);
1687 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1688 assert_eq!(typed.len(), 1);
1689
1690 // Rendered after: the component cleared its draft, and that stands.
1691 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1692 keep_typed(&mut tree, &mut typed, 2);
1693 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1694 assert!(typed.is_empty());
1695 }
1696
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1697 #[test]
1698 fn a_zero_width_request_is_no_request() {
1699 let mut t = Tree::default();
1700 let root = t.root();
1701 let column = node(&mut t, root, "vbox");
1702 t.set(column, "width-request", Prop::Num(0.0));
1703 assert_eq!(width_request(t.get(column).unwrap()), None);
1704 t.set(column, "width-request", Prop::Num(260.0));
1705 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1706 }
1707
1708 #[test]
1709 fn a_scroll_is_named_by_its_scroll_key() {
1710 let mut t = Tree::default();
1711 let root = t.root();
1712 let list = node(&mut t, root, "scroll");
1713 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1714 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1715 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1716 }
1717
1718 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1719 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1720 // A row halfway down a long backlog, in a 600pt viewport: half the
1721 // viewport above it, less half the row.
1722 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1723 // The same row with no viewport reported yet: its own top.
1724 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1725 // A row near the top cannot be centred without scrolling above the
1726 // content, and nothing is above the content.
1727 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1728 // A row taller than the viewport is shown from its own top: there is
1729 // no middle of it to put in the middle.
1730 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1731 }
1732
1733 #[test]
1734 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1735 let mut t = Tree::default();
1736 let root = t.root();
1737 let list = node(&mut t, root, "scroll");
1738 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1739 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1740 let before = t.clone();
1741 t.set(rows[3], "scroll-here", Prop::Bool(true));
1742
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1743 // 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 ago1744 let asks = scroll_asks(&before, &t);
1745 assert_eq!(asks.len(), 1);
1746 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1747 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1748
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1749 // And it goes on asking while the row is still asking: the row may not
1750 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1751 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1752 assert_eq!(again[0].reveal, Some(rows[3]));
1753
1754 // A node asking from deeper inside a row answers with the row.
1755 t.set(rows[3], "scroll-here", Prop::Bool(false));
1756 let inner = node(&mut t, rows[1], "vbox");
1757 t.set(inner, "scroll-here", Prop::Bool(true));
1758 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1759
1760 // Nothing asking, nothing to reveal.
1761 t.set(inner, "scroll-here", Prop::Bool(false));
1762 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1763 }
1764
1765 #[test]
1766 fn a_jump_is_the_counter_moving() {
1767 let mut t = Tree::default();
1768 let root = t.root();
1769 let list = node(&mut t, root, "scroll");
1770 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1771 let before = t.clone();
1772 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1773 let asks = scroll_asks(&before, &t);
1774 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1775 }
1776}