nandi/jolt-nativepublic Fork 0
6316bb13a9c5cdbc60e3d01da6880d66198ed1d9
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 · 1810 lines · 69.8 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,
232 offset_y: f32,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago233 /// How tall the viewport was when the reader last moved it, which is what
234 /// a jump centres a row in. Zero until they have: a list nobody has
235 /// scrolled has no reported height, and a row put in the middle of a
236 /// viewport of nothing is a row put at the top — which is the right answer
237 /// to give when the height is not known.
238 height: f32,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago239}
240
241/// Two points of slack: a viewport scrolled to its end by a fractional
242/// offset is still at the end.
243const AT_END_SLACK: f32 = 2.0;
244
245fn scroll_name(n: &Node, id: i32) -> String {
246 match n.str("scroll-key") {
247 "" => format!("node-{id}"),
248 key => key.to_owned(),
249 }
250}
251
252fn scroll_id(name: &str) -> widget::Id {
253 widget::Id::new(format!("jolt-scroll-{name}"))
254}
255
256fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
257 if let Some(n) = t.get(id) {
258 f(id, n);
259 for child in &n.children {
260 walk(t, *child, f);
261 }
262 }
263}
264
265/// What a commit asks of one scroll area.
266struct ScrollAsk {
267 name: String,
268 stick: bool,
269 /// The `scroll-to-bottom` counter, and what it was in the tree before.
270 tick: Option<f64>,
271 tick_before: Option<f64>,
272 /// Not in the tree before this commit: mounted, or mounted again.
273 fresh: bool,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago274 /// The row asking to be shown, if one is.
275 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago276}
277
278/// Every scroll area in `now`, and what changed about each since `before`.
279fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
280 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
281 walk(before, before.root_id(), &mut |id, n| {
282 if n.tag == "scroll" {
283 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
284 }
285 });
286
287 let mut asks = Vec::new();
288 walk(now, now.root_id(), &mut |id, n| {
289 if n.tag != "scroll" {
290 return;
291 }
292 let name = scroll_name(n, id);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago293 // The row holding whatever asked to be shown.
294 //
295 // The row, because a row is what `rows::Rows` writes a place down for;
296 // and the row rather than a guess at where it sits, because this used
297 // to answer with its index over the row count. That is a fraction of
298 // the scroll RANGE and not of the content — the two agree only when
299 // the viewport is exactly one row tall — and it took every row for the
300 // same height besides, in a backlog that puts a one-line message next
301 // to a picture. The landing was out by up to a viewport, worst in the
302 // middle of a list.
303 //
304 // While it is asking, not only on the commit the ask arrives. A row
305 // that is not laid out yet has no place written down for it, and the
306 // ask is over in half a second: asking again each commit is what lets
307 // a jump into a conversation the client has only just switched to land
308 // on the frame the rows finally exist.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago309 let mut reveal = None;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago310 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago311 let mut asked = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago312 walk(now, *row, &mut |_, node| {
313 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago314 });
315 if asked {
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago316 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago317 break;
318 }
319 }
320 asks.push(ScrollAsk {
321 fresh: !named_before.contains_key(&name),
322 tick_before: named_before.get(&name).copied().flatten(),
323 tick: n.num("scroll-to-bottom"),
324 stick: n.bool("stick-to-bottom") == Some(true),
325 reveal,
326 name,
327 });
328 });
329 asks
330}
331
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago332/// Where the rows of the scroll area called `name` were last laid out.
333///
334/// By name and not by node, because a scroll area outlives the node ids of a
335/// tree that is rebuilt under it — the same list, and the reader's place in
336/// it, is the thing `scroll-key` names.
337fn placements(name: &str) -> rows::Placements {
338 static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
339 LazyLock::new(|| Mutex::new(HashMap::new()));
340 lock(&BOOKS).entry(name.to_owned()).or_default().clone()
341}
342
343/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
344/// of a viewport `viewport` tall.
345///
346/// The middle rather than the top edge: a line answered three days ago is read
347/// with what was said around it, and a jump that pins it to the ceiling shows
348/// only what came after.
349///
350/// Never above the start of the content — a negative offset is not a place —
351/// and the top edge is the answer while the viewport's height is unknown,
352/// which it is until the reader has scrolled the list once. A row centred in a
353/// viewport of nothing is a row at the top, which is the same answer said
354/// twice, but it is worth being the one that is said on purpose.
355fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
356 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
357}
358
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago359/// Whether to say out loud what every jump decided, on stderr.
360///
361/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
362/// and which of them is wrong is not a thing anyone can tell from a window
363/// that scrolled to the wrong place.
364fn scroll_log() -> bool {
365 static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
366 *ON
367}
368
369/// How many frames a reveal keeps trying for.
370///
371/// A row is measured by the layout that draws it, so the frame a jump is asked
372/// on is a frame too early: the places written down are the ones from before
373/// the room changed. Twenty frames is a third of a second at sixty, which is
374/// longer than a screen takes to build and shorter than a reader waits before
375/// deciding nothing happened.
376const REVEAL_TRIES: u8 = 20;
377
378/// The row of the scroll area called `name` that is asking to be shown, as the
379/// tree has it now.
380///
381/// Asked again on every attempt rather than carried, because a row is not the
382/// same node for long. A buffer that takes a line while a jump is landing is
383/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
384/// node 587 now — so a retry holding the old number would look up a place for
385/// a row nobody has, and go on failing until it gave up. Which room a reader
386/// jumped into decided whether it worked, and that is exactly as strange as
387/// it sounds until you see what it depends on.
388fn asking_row(t: &Tree, name: &str) -> Option<i32> {
389 let mut found = None;
390 walk(t, t.root_id(), &mut |id, n| {
391 if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
392 return;
393 }
394 for row in &n.children {
395 let mut asked = false;
396 walk(t, *row, &mut |_, node| {
397 asked |= node.bool("scroll-here") == Some(true);
398 });
399 if asked {
400 found = Some(*row);
401 break;
402 }
403 }
404 });
405 found
406}
407
408/// Ask to be taken to the row of `name` that wants showing — now if its place
409/// is known, and on the next frame if it is not.
410///
411/// A task that is already finished is not a wasted frame: iced takes its
412/// message on the next pass of the loop, which is after this frame has been
413/// laid out — and being laid out is exactly what the row has to have done for
414/// there to be an answer.
415fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
416 match placements(&name).get(row) {
417 Some((top, height)) => {
418 let y = centred_offset(top, height, viewport);
419 if scroll_log() {
420 eprintln!(
421 "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
422 );
423 }
424 iced_scrollable::scroll_to(scroll_id(&name), AbsoluteOffset { x: None, y: Some(y) })
425 }
426 None => {
427 if scroll_log() {
428 eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
429 }
430 Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
431 }
432 }
433}
434
435/// How many rows the scroll area called `name` has, and how many of them are
436/// asking to be shown. For the log alone.
437fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
438 let mut shape = (0, 0);
439 walk(t, t.root_id(), &mut |id, n| {
440 if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
441 return;
442 }
443 shape.0 = n.children.len();
444 for row in &n.children {
445 let mut asked = false;
446 walk(t, *row, &mut |_, node| {
447 asked |= node.bool("scroll-here") == Some(true);
448 });
449 if asked {
450 shape.1 += 1;
451 }
452 }
453 });
454 shape
455}
456
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago457fn snap_to_end(name: &str) -> Task<Message> {
458 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
459}
460
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago461// --- the app -----------------------------------------------------------------
462
463struct App {
464 core: Core,
465 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago466 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago467 /// What the window last wrote back into a control, by node and prop, with
468 /// the sequence number of the event that carried it to the worker.
469 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago470}
471
472#[derive(Clone, Debug)]
473enum Message {
474 Tree,
475 Quit,
476 Click(i32),
477 Toggled(i32, bool),
478 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago479 Paste(i32, String),
480 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago481 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago482 Hover(i32),
483 Unhover(i32),
484 Scrolled(i32, String, Viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago485 /// Show whichever row of this scroll area is asking to be shown, and how
486 /// many more frames to keep trying for. See `reveal`.
487 Reveal(String, u8),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago488 PickImage,
489 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago490}
491
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago492/// Lay what was typed over a commit that has not caught up with it.
493///
494/// libcosmic paints a control from the tree, so a commit rendered before the
495/// worker saw the latest keystroke would put the older text back under the
496/// caret, and the next key would land on that. An entry is let go once a
497/// commit was rendered after its event: from then on the component's own
498/// state is the answer, a draft it cleared included.
499fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) {
500 typed.retain(|&(node, key), (seq, value)| {
501 let Some(n) = tree.get(node) else { return false };
502 if *seq <= settled {
503 return false;
504 }
505 if n.props.get(key) != Some(value) {
506 Arc::make_mut(tree).set(node, key, value.clone());
507 }
508 true
509 });
510}
511
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago512impl App {
513 /// A widget does not own its value: the new state goes into the arena and
514 /// 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 ago515 /// working control, and its next render is what settles it. Then the event
516 /// goes to the worker, and what was written is held over any commit
517 /// rendered before the worker saw it.
518 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 ago519 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 ago520 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
521 let seq = post_seq(node, event, text, num);
522 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago523 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago524
525 /// Take the committed tree, and move every scroll area to where it should
526 /// be now that it has changed.
527 ///
528 /// A snap is relative, so a list snapped to its end stays at its end as
529 /// rows arrive under it, until the reader scrolls away.
530 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago531 let (committed, settled) = {
532 let c = lock(&COMMITTED);
533 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
534 };
535 let before = std::mem::replace(&mut self.tree, committed);
536 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago537 let mut tasks = Vec::new();
538 let mut live = HashSet::new();
539 for ask in scroll_asks(&before, &self.tree) {
540 live.insert(ask.name.clone());
541 let memo = self
542 .scrolls
543 .entry(ask.name.clone())
544 .or_insert(ScrollMemo {
545 at_end: ask.stick,
546 offset_y: 0.0,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago547 height: 0.0,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago548 });
549 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 ago550 // A row asking to be shown, and a place written down for it by the
551 // last layout. Both, or there is nothing to do yet: the row is
552 // measured on the frame it appears, and the ask stands until it
553 // has been.
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago554 // A row is asking to be shown. Whether or not it can be shown yet,
555 // nothing else may move this list while it is asking: the branch
556 // below would otherwise take a reader who was at the newest line —
557 // which is most readers, most of the time — straight back to it,
558 // and a jump that ends at the bottom of the room reads as a jump
559 // that did nothing.
560 if let Some(row) = ask.reveal {
Leave the end flag to the thing that can see the end e39a374 nandi 8d ago561 // The memo is not told where this lands. `scrolled` is what
562 // knows, and it tells the client only when the answer CHANGES
563 // — so a memo that marked itself away from the end here stole
564 // that change from it: the list moved, the client was never
565 // told, and it went on believing the reader was at the newest
566 // line. Which is a "jump to present" button that does not
567 // appear until the reader scrolls to the present by hand.
Keep asking until the row is there, and say so when it is not b49f82a nandi 8d ago568 tasks.push(reveal(ask.name.clone(), row, memo.height));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago569 } else if jumped || (ask.stick && memo.at_end) {
570 memo.at_end = true;
571 tasks.push(snap_to_end(&ask.name));
572 } else if ask.fresh {
573 tasks.push(iced_scrollable::scroll_to(
574 scroll_id(&ask.name),
575 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
576 ));
577 }
578 }
579 // A list that was never scrolled keeps no memo worth the space; one
580 // that was keeps its place for when it comes back.
581 self.scrolls
582 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
583 Task::batch(tasks)
584 }
585
586 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
587 let y = viewport.absolute_offset().y;
588 let room = viewport.content_bounds().height - viewport.bounds().height;
589 let at_end = room - y <= AT_END_SLACK;
590 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
591 at_end,
592 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago593 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago594 });
595 let was = memo.at_end;
596 memo.at_end = at_end;
597 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago598 memo.height = viewport.bounds().height;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago599 // "end" or "away", the strings libvidya emits: frq's handler compares
600 // against "end".
601 if was != at_end {
602 let place = if at_end { "end" } else { "away" };
603 post(node, "change", place.to_owned(), 0.0);
604 }
605 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago606}
607
608impl cosmic::Application for App {
609 type Executor = cosmic::executor::Default;
610 type Flags = String;
611 type Message = Message;
612 const APP_ID: &'static str = "dev.jolt.Glimmer";
613
614 fn core(&self) -> &Core {
615 &self.core
616 }
617
618 fn core_mut(&mut self) -> &mut Core {
619 &mut self.core
620 }
621
622 fn init(core: Core, title: String) -> (Self, Task<Message>) {
623 let mut app = App {
624 core,
625 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago626 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago627 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago628 };
629 // libcosmic's `wayland` feature brings `multi-window` with it, which
630 // makes a window title a per-window thing.
631 app.set_header_title(title.clone());
632 let task = match app.core.main_window_id() {
633 Some(id) => app.set_window_title(title, id),
634 None => Task::none(),
635 };
636 (app, task)
637 }
638
639 fn subscription(&self) -> Subscription<Message> {
640 Subscription::run(wakes)
641 }
642
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago643 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
644 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
645 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
646 }
647
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago648 fn update(&mut self, message: Message) -> Task<Message> {
649 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago650 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago651 Message::Quit => return cosmic::iced::exit(),
652 Message::Click(node) => post(node, "click", String::new(), 0.0),
653 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago654 let num = f64::from(u8::from(on));
655 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago656 }
657 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago658 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
659 }
660 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
661 // clipboard holding a picture has none, so the field comes back as
662 // it was. That is the paste worth reporting: the picture is read
663 // here, where the clipboard is, and `paste-empty` goes to the
664 // worker, which collects it with `cosmic_clipboard_image_png`.
665 Message::Paste(node, text) => {
666 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
667 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
668 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
669 }
670 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
671 }
672 Message::PastedPicture(node, png) => {
673 *lock(&CLIPBOARD_PNG) = png;
674 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago675 }
676 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago677 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
678 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
679 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 ago680 // The row was not laid out when the jump was asked for. Look
681 // again, and keep looking for a few frames: a room the reader has
682 // only just been taken to has to be built before its lines have
683 // anywhere to be.
684 Message::Reveal(name, tries) => {
685 let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
686 let place = asking_row(&self.tree, &name)
687 .and_then(|row| placements(&name).get(row));
688 if let Some((top, height)) = place {
Leave the end flag to the thing that can see the end e39a374 nandi 8d ago689 // Nothing written down here either, for the reason the
690 // commit path gives: where this ends up is `scrolled`'s to
691 // 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 ago692 let y = centred_offset(top, height, viewport);
693 return iced_scrollable::scroll_to(
694 scroll_id(&name),
695 AbsoluteOffset { x: None, y: Some(y) },
696 );
697 }
698 if scroll_log() {
699 let asking = asking_row(&self.tree, &name);
700 let (rows, here) = scroll_shape(&self.tree, &name);
701 eprintln!(
702 "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
703 {rows} rows, {here} asking to be shown, \
704 {} placed, viewport {viewport}",
705 placements(&name).len()
706 );
707 }
708 // Not landed yet. Keep trying for the whole budget rather
709 // than stopping the moment nothing is asking: a room the
710 // reader has just been taken to is built over several frames,
711 // and one where the rows are not in the tree yet looks exactly
712 // like a jump that is over. It is not over, it is early.
713 if tries > 0 {
714 return Task::future(async move {
715 cosmic::Action::App(Message::Reveal(name, tries - 1))
716 });
717 }
718 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago719 // The desktop's own chooser, through the portal, on libcosmic's
720 // executor: it is a D-Bus round trip, and the window keeps
721 // painting while it is open.
722 Message::PickImage => {
723 return Task::perform(
724 async {
725 rfd::AsyncFileDialog::new()
726 .set_title("Choose a picture")
727 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
728 .pick_file()
729 .await
730 .map(|file| file.path().to_path_buf())
731 },
732 |path| cosmic::Action::App(Message::Picked(path)),
733 );
734 }
735 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago736 }
737 Task::none()
738 }
739
740 fn view(&self) -> Element<'_, Message> {
741 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago742 let root = element(tree, tree.root_id(), true, false);
743 // A dialog that asked not to be modal, put up here rather than handed
744 // to `dialog` below. It is the same widget in the same place — a
745 // `popover` centres it exactly as `cosmic::app` does — and the whole
746 // of the difference is that this one is not told to intercept the
747 // pointer. That matters to anything the pointer opened: a modal
748 // popover hands the window underneath it a cursor that is
749 // `Unavailable`, so a face that opened a dialog on hover never hears
750 // the pointer leave, and what it opened can never close itself.
751 //
752 // The popover is here whether or not there is anything in it, which
753 // `cosmic::app` says of its own in one line and which this learned
754 // the long way: iced keeps a widget's state by where it sits in the
755 // tree, so a wrapper that comes and goes rebuilds everything under
756 // it — and what "everything" holds is the scroll positions. Wrapping
757 // only when a dialog appeared meant resting the pointer on a face
758 // jumped the conversation behind it.
759 let mut popover = widget::popover(root);
760 if let Some(id) = find_dialog(tree, false) {
761 // The dialog reports its own pointer, on the same two events a
762 // face or a pill reports theirs. Without it a dialog the pointer
763 // opened can only be read at arm's length: the client is told the
764 // pointer left what opened it and never told it arrived here, so
765 // the one way to keep it up is not to move — and everything in it
766 // is out of reach.
767 let popup = widget::mouse_area(dialog_of(tree, id))
768 .on_enter(Message::Hover(id))
769 .on_exit(Message::Unhover(id));
770 popover = popover.popup(popup);
771 }
772 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago773 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago774
775 /// The MODAL dialog the tree is carrying, if it is carrying one.
776 ///
777 /// A client says there is one by putting a `dialog` node in the tree and
778 /// says there is not by leaving it out — the same way it says anything
779 /// else. What comes back is libcosmic's own dialog: centred, over a
780 /// dimmed window, and closed by the buttons the client hung on it.
781 ///
782 /// A dialog that says `modal false` does not come back here. This hook is
783 /// the modal one whether the client wants it or not — `cosmic::app` wraps
784 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
785 /// that kind up itself. See `dialog_of`.
786 fn dialog(&self) -> Option<Element<'_, Message>> {
787 let tree = &*self.tree;
788 let id = find_dialog(tree, true)?;
789 Some(dialog_of(tree, id))
790 }
791}
792
793/// The first `dialog` node in the tree whose modality is `modal`.
794///
795/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
796/// not, which is the shape everything else here takes — a prop left out is
797/// the ordinary answer.
798fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
799 let mut found = None;
800 walk(t, t.root_id(), &mut |id, n| {
801 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
802 found = Some(id);
803 }
804 });
805 found
806}
807
808/// One `dialog` node as libcosmic's dialog.
809///
810/// `label` is its heading and `body` the line under it. Children are its
811/// controls, in order, except that a child carrying `slot` "primary" or
812/// "secondary" becomes that action instead — which is where libcosmic puts
813/// the buttons, at the foot and to the right.
814fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
815 let Some(n) = t.get(id) else {
816 return widget::Space::new().width(0).height(0).into();
817 };
818 let mut d = widget::dialog();
819 if !n.label().is_empty() {
820 d = d.title(n.label().to_owned());
821 }
822 if !n.str("body").is_empty() {
823 d = d.body(n.str("body").to_owned());
824 }
825 if let Some(w) = n.num("max-width") {
826 d = d.max_width(w as f32);
827 }
828 for child in &n.children {
829 let Some(c) = t.get(*child) else { continue };
830 let el = element(t, *child, true, false);
831 d = match c.str("slot") {
832 "primary" => d.primary_action(el),
833 "secondary" => d.secondary_action(el),
834 _ => d.control(el),
835 };
836 }
837 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago838}
839
840// --- props into layout -----------------------------------------------------------
841
842/// `margin` all round, with `margin-top` and its siblings overriding a side.
843fn margins(n: &Node) -> Padding {
844 let all = n.num("margin").unwrap_or(0.0) as f32;
845 let side = |key| n.num(key).map_or(all, |v| v as f32);
846 Padding {
847 top: side("margin-top"),
848 right: side("margin-right"),
849 bottom: side("margin-bottom"),
850 left: side("margin-left"),
851 }
852}
853
854/// A width the client asked for. Zero is the client saying "none": frq writes
855/// `:width-request 0` on its message column whenever the people panel is shut,
856/// and taken literally that is a backlog laid out zero points wide.
857fn width_request(n: &Node) -> Option<f32> {
858 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
859}
860
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago861/// `align`, or `default` where it is not set. A column starts its children at
862/// the left. Rows do not ask: `align` on a row is where along the row its
863/// children sit, not how they line up across it, and the row branch of
864/// `element` reads it itself.
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago865fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago866 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago867 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago868 "center" => Alignment::Center,
869 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago870 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago871 }
872}
873
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago874fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
875 cosmic::theme::Container::custom(move |_| ContainerStyle {
876 background: Some(Background::Color(color)),
877 border: Border {
878 radius: radius.into(),
879 ..Border::default()
880 },
881 text_color: Some(Color::WHITE),
882 ..ContainerStyle::default()
883 })
884}
885
886/// A colour for somebody, from their name, so the same person is the same
887/// colour everywhere they appear.
888fn name_colour(name: &str) -> Color {
889 const PALETTE: [(f32, f32, f32); 8] = [
890 (0.83, 0.33, 0.33),
891 (0.85, 0.55, 0.20),
892 (0.62, 0.62, 0.18),
893 (0.30, 0.65, 0.35),
894 (0.20, 0.62, 0.62),
895 (0.30, 0.50, 0.85),
896 (0.55, 0.40, 0.85),
897 (0.80, 0.35, 0.65),
898 ];
899 let hash = name
900 .bytes()
901 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
902 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
903 Color::from_rgb(r, g, b)
904}
905
906/// A picture that answers a click, with the pointer saying so.
907fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
908 if !enabled {
909 return el;
910 }
911 widget::mouse_area(el)
912 .on_press(Message::Click(id))
913 .interaction(cosmic::iced::mouse::Interaction::Pointer)
914 .into()
915}
916
917fn picture(path: &str) -> Option<widget::image::Handle> {
918 (!path.is_empty() && std::path::Path::new(path).exists())
919 .then(|| widget::image::Handle::from_path(path))
920}
921
922// --- the tree into widgets ---------------------------------------------------------
923
924/// One node and everything under it, as widgets.
925///
926/// `enabled` is inherited: an insensitive container takes its whole subtree out
927/// of interaction. `in_row` is whether the parent lays its children out across:
928/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
929/// column in a column takes the width and a column in a row does not take the
930/// row's slack unless it says `fill-height`.
931fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago932 let Some(n) = t.get(id) else {
933 return Column::new().into();
934 };
935 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago936 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago937 // glimmer-jvui's theme spacing, where the client does not say: a list of
938 // cards with nothing between them reads as one slab.
939 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago940 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 ago941
942 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago943 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago944 .width(Length::Fill)
945 .height(Length::Fill)
946 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago947 // Sizes are set only where something asked for one. iced's rows and
948 // columns take `Fill` on an axis from any child that fills it, which is
949 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
950 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
951 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago952 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago953 let across = n.str("orientation") == "horizontal";
954 if across {
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago955 // `align` on a row is the MAIN axis, which is glimmer's
956 // meaning and the one the shared screens are written against:
957 // `:end` lays the children out *from* the right, so the first
958 // child in the source is the rightmost on screen. Read as a
959 // cross-axis gravity instead it did nothing visible but sit
960 // the chips low, and the message heading's pair came out in
961 // the order ✏️ ↩️ 🙂 hard against the clock rather than the
962 // other way round against the edge — see `chat/action-chips`.
963 let from_end = n.str("align") == "end";
964 let mut row = if from_end {
965 Row::with_children(children(true).collect::<Vec<_>>().into_iter().rev())
966 } else {
967 Row::with_children(children(true))
968 }
969 .spacing(spacing)
970 .padding(margins(n))
971 // Across the row the children still centre: a chip beside a
972 // label sitting against the top of it is what that is for.
973 .align_y(Alignment::Center);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago974 // A row fills the width it is in only when it or something in
975 // it asks to; otherwise a line of buttons would spread out.
976 match width_request(n) {
977 Some(w) => row = row.width(w),
978 None if fill_height => row = row.width(Length::Fill),
979 None => {}
980 }
981 if fill_height {
982 row = row.height(Length::Fill);
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago983 }
984 if from_end {
985 // iced has no main-axis alignment on a Row, so the edge is
986 // a container's doing: it takes the width and puts the row
987 // against the right of it. Not a leading Fill space, which
988 // would have halved the slack with a row that already has
989 // something filling in it — the join box beside its button
990 // is that row, and the box is meant to take all of it.
991 return widget::container(row)
992 .width(Length::Fill)
993 .align_x(Horizontal::Right)
994 .into();
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago995 }
996 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago997 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago998 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago999 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1000 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1001 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1002 match width_request(n) {
1003 Some(w) => column = column.width(w),
1004 None if fill_height || !in_row => column = column.width(Length::Fill),
1005 None => {}
1006 }
1007 if fill_height {
1008 column = column.height(Length::Fill);
1009 }
1010 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1011 }
1012 }
1013 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1014 let column = Column::with_children(children(false))
1015 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1016 .padding(24)
1017 .width(Length::Fill);
1018 let mut inner = widget::container(column).width(Length::Fill);
1019 if let Some(max) = n.num("max-width") {
1020 inner = inner.max_width(max as f32);
1021 }
1022 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1023 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1024 .height(Length::Fill)
1025 .into()
1026 }
1027 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1028 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 ago1029 if n.tag == "frame" && !n.label().is_empty() {
1030 column = column.push(widget::text::heading(n.label()));
1031 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1032 let card = widget::container(column.extend(children(false)))
1033 .padding(12)
1034 .class(cosmic::theme::Container::Card);
1035 match width_request(n) {
1036 Some(w) => card.width(w).into(),
1037 None if !in_row => card.width(Length::Fill).into(),
1038 None => card.into(),
1039 }
1040 }
1041 // Always fills both ways: a viewport that only fills its width asks its
1042 // column for no height, and is given none. The content is held to its
1043 // own height, since iced will not scroll content that fills the axis it
1044 // scrolls along.
1045 "scroll" => {
1046 let name = scroll_name(n, id);
1047 let content = Column::with_children(children(false))
1048 .spacing(spacing)
1049 .width(Length::Fill)
1050 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1051 // Wrapped in the thing that writes down where each row landed, so
1052 // that "take me to this line" has an answer in points — which is
1053 // the only thing a scroll area can be told. See `rows`.
1054 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1055 widget::scrollable(content)
1056 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1057 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1058 .height(Length::Fill)
1059 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1060 .into()
1061 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1062 // Word wrapping that falls back to breaking inside a word: a URL is one
1063 // word, and it otherwise runs straight past the edge of its column.
1064 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
1065 .wrapping(Wrapping::WordOrGlyph)
1066 .into(),
1067 "label" => widget::text::body(n.label())
1068 .wrapping(Wrapping::WordOrGlyph)
1069 .into(),
1070 "title" => widget::text::title3(n.label())
1071 .wrapping(Wrapping::WordOrGlyph)
1072 .into(),
1073 "title-2" => widget::text::title4(n.label())
1074 .wrapping(Wrapping::WordOrGlyph)
1075 .into(),
1076 "dim-label" => widget::text::caption(n.label())
1077 .wrapping(Wrapping::WordOrGlyph)
1078 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1079 "button" => {
1080 let button = match n.str("kind") {
1081 "primary" => widget::button::suggested(n.label()),
1082 "destructive" => widget::button::destructive(n.label()),
1083 _ => widget::button::standard(n.label()),
1084 };
1085 button
1086 .on_press_maybe(enabled.then_some(Message::Click(id)))
1087 .into()
1088 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1089 "link" => widget::button::link(n.label().to_owned())
1090 .on_press_maybe(enabled.then_some(Message::Click(id)))
1091 .into(),
1092 // A dot that says whether the thing is live, and the words beside it.
1093 "status" => {
1094 let colour = if n.bool("live") == Some(true) {
1095 Color::from_rgb(0.30, 0.72, 0.40)
1096 } else {
1097 Color::from_rgb(0.55, 0.55, 0.55)
1098 };
1099 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
1100 Row::new()
1101 .spacing(6)
1102 .align_y(Alignment::Center)
1103 .push(dot)
1104 .push(widget::text::caption(n.label()))
1105 .into()
1106 }
1107 "spinner" => {
1108 let mut row = Row::new()
1109 .spacing(8)
1110 .align_y(Alignment::Center)
1111 .push(widget::progress_bar::indeterminate_circular().size(16.0));
1112 if !n.label().is_empty() {
1113 row = row.push(widget::text::caption(n.label()));
1114 }
1115 row.into()
1116 }
1117 "emoji" => {
1118 let glyph = match n.str("emoji") {
1119 "" => n.label(),
1120 e => e,
1121 };
1122 widget::text(glyph.to_owned())
1123 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1124 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1125 .into()
1126 }
1127 // A round picture, or the initial on a colour from the name: most
1128 // 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 ago1129 //
1130 // And the three things a face is for besides being looked at. It
1131 // painted as a picture and nothing else until now: a client that
1132 // asked a face to answer a click, to report the pointer arriving, or
1133 // to carry a card under it was handed a portrait that did none of
1134 // them — so the profile behind every avatar in the window was
1135 // unreachable, and the hover card written for it never appeared.
1136 // Those are the same three things `reaction` below does, so they are
1137 // done the same way: `mouse_area` for the press and the two edges of
1138 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1139 "avatar" => {
1140 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 ago1141 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1142 Some(handle) => widget::image(handle)
1143 .width(size)
1144 .height(size)
1145 .content_fit(ContentFit::Cover)
1146 .border_radius(size / 2.0)
1147 .into(),
1148 None => {
1149 let initial: String = n
1150 .label()
1151 .trim_start_matches(|c: char| !c.is_alphanumeric())
1152 .chars()
1153 .next()
1154 .map(|c| c.to_uppercase().collect())
1155 .unwrap_or_default();
1156 widget::container(widget::text(initial).size(size * 0.45))
1157 .center(Length::Fixed(size))
1158 .class(filled(name_colour(n.label()), size / 2.0))
1159 .into()
1160 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1161 };
1162 // The hover is reported whether or not the face is enabled: it
1163 // says where the pointer is, which is true of an insensitive
1164 // picture too. The press is not — an insensitive subtree is out
1165 // of interaction, which is what `enabled` means here.
1166 let mut area = widget::mouse_area(face)
1167 .on_enter(Message::Hover(id))
1168 .on_exit(Message::Unhover(id));
1169 if enabled {
1170 area = area
1171 .on_press(Message::Click(id))
1172 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1173 }
1174 if n.children.is_empty() {
1175 area.into()
1176 } else {
1177 widget::tooltip(
1178 area,
1179 Column::with_children(children(false)).spacing(4),
1180 widget::tooltip::Position::Bottom,
1181 )
1182 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1183 }
1184 }
1185 // A pill: an emoji, how many people, and whether you are one of them.
1186 // What the client hangs under it is its hover card, shown while the
1187 // pointer is on the pill.
1188 "reaction" => {
1189 let glyph = match n.str("emoji") {
1190 "" => n.label(),
1191 e => e,
1192 };
1193 let size = n.num("size").unwrap_or(16.0) as f32;
1194 let mut content = Row::new()
1195 .spacing(4)
1196 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1197 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1198 let count = n.num("count").unwrap_or(0.0);
1199 if count > 0.0 {
1200 content = content.push(widget::text::caption(format!("{count}")));
1201 }
1202 let class = if n.bool("mine") == Some(true) {
1203 widget::button::ButtonClass::Suggested
1204 } else {
1205 widget::button::ButtonClass::Standard
1206 };
1207 let pill = widget::button::custom(content)
1208 .padding([2, 8])
1209 .class(class)
1210 .on_press_maybe(enabled.then_some(Message::Click(id)));
1211 let pill = widget::mouse_area(pill)
1212 .on_enter(Message::Hover(id))
1213 .on_exit(Message::Unhover(id));
1214 if n.children.is_empty() {
1215 pill.into()
1216 } else {
1217 widget::tooltip(
1218 pill,
1219 Column::with_children(children(false)).spacing(4),
1220 widget::tooltip::Position::Bottom,
1221 )
1222 .into()
1223 }
1224 }
1225 // One tag for both kinds of picture, as in libvidya. `feed` is live
1226 // pixels pushed under a name, which nothing pushes here yet, so it
1227 // holds the slot the layout gave it.
1228 "image" => {
1229 let max_w = n.num("max-width").map(|v| v as f32);
1230 let max_h = n.num("max-height").map(|v| v as f32);
1231 if !n.str("feed").is_empty() {
1232 let w = max_w.unwrap_or(160.0);
1233 let h = max_h.unwrap_or(w * 0.75);
1234 widget::container(widget::text::caption("video"))
1235 .center_x(Length::Fixed(w))
1236 .center_y(Length::Fixed(h))
1237 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1238 .into()
1239 } else if let Some(handle) = picture(n.str("src")) {
1240 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1241 if n.bool("fit") == Some(true) {
1242 image = image.width(Length::Fill).height(Length::Fill);
1243 } else if let Some(size) = n.num("size") {
1244 image = image.width(size as f32).height(size as f32);
1245 }
1246 let mut bounded = widget::container(image);
1247 if let Some(w) = max_w {
1248 bounded = bounded.max_width(w);
1249 }
1250 if let Some(h) = max_h {
1251 bounded = bounded.max_height(h);
1252 }
1253 clickable(bounded.into(), id, enabled)
1254 } else {
1255 widget::Space::new().width(0).height(0).into()
1256 }
1257 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1258 "checkbutton" => {
1259 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1260 if enabled {
1261 check = check.on_toggle(move |on| Message::Toggled(id, on));
1262 }
1263 check.into()
1264 }
1265 "entry" => {
1266 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1267 if enabled {
1268 entry = entry
1269 .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 ago1270 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1271 .on_submit(move |_| Message::Activate(id));
1272 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1273 let width = match width_request(n) {
1274 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1275 _ => Length::Fill,
1276 };
1277 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1278 }
1279 "separator" => widget::divider::horizontal::default().into(),
1280 "spacer" => {
1281 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1282 if n.str("expand").is_empty() {
1283 widget::Space::new().width(size).height(size).into()
1284 } else {
1285 widget::Space::new().width(Length::Fill).height(size).into()
1286 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1287 }
1288 "progress" => {
1289 let bar =
1290 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1291 if n.label().is_empty() {
1292 bar.into()
1293 } else {
1294 Column::new()
1295 .spacing(4)
1296 .push(widget::text::caption(n.label()))
1297 .push(bar)
1298 .into()
1299 }
1300 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1301 // The one node that is not painted where it stands. libcosmic puts a
1302 // dialog up itself, centred over the window and dimming what is
1303 // behind it — `Application::dialog` is the hook, and it is asked for
1304 // one separately from `view`. So the tree carries the dialog wherever
1305 // the client found it convenient to write it, `App::dialog` goes and
1306 // finds it there, and this leaves nothing behind in the layout. A
1307 // node rendered in both places would be painted twice.
1308 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1309 // Kept rather than refused, as in libvidya: a tag this backend has not
1310 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1311 _ => Column::with_children(children(false))
1312 .spacing(spacing)
1313 .padding(margins(n))
1314 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1315 };
1316
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1317 // The containers and the entry size themselves above; anything else asked
1318 // for a width gets it from a wrapper.
1319 match (n.tag.as_str(), width_request(n)) {
1320 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1321 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1322 }
1323}
1324
1325// --- the C ABI: the loop -------------------------------------------------------
1326
1327static TITLE: Mutex<String> = Mutex::new(String::new());
1328
1329/// The window's title, read when `cosmic_run` opens it. A call of its own
1330/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1331/// `cosmic_run` has to be one.
1332///
1333/// # Safety
1334/// `title` is null or a NUL-terminated string.
1335#[no_mangle]
1336pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1337 let title = borrowed(title);
1338 guard((), || *lock(&TITLE) = title)
1339}
1340
1341/// Open the window and run libcosmic until it closes. Blocks; call it on the
1342/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1343///
1344/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1345/// in this process — winit's event loop cannot be made twice.
1346#[no_mangle]
1347pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1348 let status = guard(1, || {
1349 let title = lock(&TITLE).clone();
1350 if RAN.swap(true, SeqCst) {
1351 log::error!("jolt-cosmic: a window already ran in this process");
1352 return 2;
1353 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1354 // The size asked for, until libcosmic reports the one it got.
1355 WINDOW_W.store(width.max(1) as u32, SeqCst);
1356 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1357 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1358 let mut settings = cosmic::app::Settings::default().size(size);
1359 match mode {
1360 1 => settings = settings.theme(cosmic::Theme::dark()),
1361 2 => settings = settings.theme(cosmic::Theme::light()),
1362 _ => {}
1363 }
1364 match cosmic::app::run::<App>(settings, title) {
1365 Ok(()) => 0,
1366 Err(err) => {
1367 eprintln!("jolt-cosmic: {err}");
1368 1
1369 }
1370 }
1371 });
1372 // Outside the guard, so a panic in libcosmic still releases the worker.
1373 *lock(&TO_APP) = None;
1374 CLOSED.store(true, SeqCst);
1375 BELL.notify_all();
1376 status
1377}
1378
1379/// 1 once `cosmic_run` has returned.
1380#[no_mangle]
1381pub extern "C" fn cosmic_should_close() -> c_int {
1382 c_int::from(CLOSED.load(SeqCst))
1383}
1384
1385/// Close the window. Asked before the window exists, it closes on opening.
1386#[no_mangle]
1387pub extern "C" fn cosmic_quit() {
1388 guard((), || {
1389 QUIT_ASKED.store(true, SeqCst);
1390 tell_app(Wake::Quit);
1391 })
1392}
1393
1394/// Publish the edits since the last commit. Answers 1 when there were any.
1395#[no_mangle]
1396pub extern "C" fn cosmic_tree_commit() -> c_int {
1397 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1398 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1399 let snapshot = {
1400 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1401 // A pass that only settled events still publishes, so a control
1402 // holding typed text over an older commit lets go of it.
1403 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1404 return 0;
1405 }
1406 e.dirty = false;
1407 Arc::new(e.tree.clone())
1408 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1409 {
1410 let mut committed = lock(&COMMITTED);
1411 *committed = snapshot;
1412 COMMITTED_SETTLED.store(settled, SeqCst);
1413 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1414 tell_app(Wake::Tree);
1415 1
1416 })
1417}
1418
1419/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1420/// closing. Answers 1 when an event is waiting.
1421#[no_mangle]
1422pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1423 guard(0, || {
1424 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1425 let (mut inbox, _) = BELL
1426 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1427 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1428 })
1429 .unwrap_or_else(|poisoned| poisoned.into_inner());
1430 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1431 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1432 c_int::from(!inbox.queue.is_empty())
1433 })
1434}
1435
1436/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1437#[no_mangle]
1438pub extern "C" fn cosmic_wake() {
1439 guard((), || {
1440 lock(&INBOX).woken = true;
1441 BELL.notify_all();
1442 })
1443}
1444
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1445// --- the C ABI: the window and the desktop -----------------------------------------
1446
1447/// The window's width in points; the size asked for until it has opened.
1448#[no_mangle]
1449pub extern "C" fn cosmic_window_width() -> c_int {
1450 WINDOW_W.load(SeqCst) as c_int
1451}
1452
1453#[no_mangle]
1454pub extern "C" fn cosmic_window_height() -> c_int {
1455 WINDOW_H.load(SeqCst) as c_int
1456}
1457
1458/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1459/// there is no window to ask from; the choice arrives through
1460/// `cosmic_picked_image`.
1461#[no_mangle]
1462pub extern "C" fn cosmic_pick_image() -> c_int {
1463 guard(0, || {
1464 if lock(&TO_APP).is_none() {
1465 return 0;
1466 }
1467 *lock(&PICK) = Pick::Open;
1468 tell_app(Wake::PickImage);
1469 1
1470 })
1471}
1472
1473/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1474/// was chosen since the last call; 0 while the chooser is open, after it was
1475/// cancelled, or when the picture could not be read.
1476///
1477/// # Safety
1478/// `path` is null or a NUL-terminated string.
1479#[no_mangle]
1480pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1481 let path = borrowed(path);
1482 guard(0, || {
1483 let chosen = {
1484 let mut pick = lock(&PICK);
1485 match std::mem::replace(&mut *pick, Pick::Idle) {
1486 Pick::Chosen(chosen) => chosen,
1487 other => {
1488 *pick = other;
1489 return 0;
1490 }
1491 }
1492 };
1493 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1494 Ok(()) => 1,
1495 Err(err) => {
1496 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1497 0
1498 }
1499 }
1500 })
1501}
1502
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1503/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1504/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1505/// already taken, or when the file could not be written.
1506///
1507/// # Safety
1508/// `path` is null or a NUL-terminated string.
1509#[no_mangle]
1510pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1511 let path = borrowed(path);
1512 guard(0, || {
1513 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1514 return 0;
1515 };
1516 match std::fs::write(&*path, png) {
1517 Ok(()) => 1,
1518 Err(err) => {
1519 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1520 0
1521 }
1522 }
1523 })
1524}
1525
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1526// --- the C ABI: events -----------------------------------------------------------
1527
1528static EVENT_NAME: Scratch = Scratch::new();
1529static EVENT_TEXT: Scratch = Scratch::new();
1530
1531/// Dequeue one event; 1 while there was one. The accessors describe it.
1532#[no_mangle]
1533pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1534 guard(0, || {
1535 let mut inbox = lock(&INBOX);
1536 let next = inbox.queue.pop_front();
1537 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1538 if let Some(e) = &next {
1539 inbox.taken = e.seq;
1540 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1541 inbox.current = next;
1542 c_int::from(got)
1543 })
1544}
1545
1546#[no_mangle]
1547pub extern "C" fn cosmic_tree_event_node() -> c_int {
1548 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1549}
1550
1551#[no_mangle]
1552pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1553 guard(empty_str(), || {
1554 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1555 })
1556}
1557
1558#[no_mangle]
1559pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1560 guard(empty_str(), || {
1561 let text = lock(&INBOX)
1562 .current
1563 .as_ref()
1564 .map(|e| e.text.clone())
1565 .unwrap_or_default();
1566 EVENT_TEXT.lend(text)
1567 })
1568}
1569
1570#[no_mangle]
1571pub extern "C" fn cosmic_tree_event_num() -> f64 {
1572 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1573}
1574
1575// --- the C ABI: nodes --------------------------------------------------------------
1576
1577static PROPS: Scratch = Scratch::new();
1578static DUMP: Scratch = Scratch::new();
1579
1580#[no_mangle]
1581pub extern "C" fn cosmic_tree_root() -> c_int {
1582 guard(0, || edit(Tree::root))
1583}
1584
1585/// # Safety
1586/// `tag` is null or a NUL-terminated string.
1587#[no_mangle]
1588pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1589 let tag = borrowed(tag);
1590 guard(0, || edit(|t| t.new_node(&tag)))
1591}
1592
1593#[no_mangle]
1594pub extern "C" fn cosmic_node_free(node: c_int) {
1595 guard((), || edit(|t| t.free(node)))
1596}
1597
1598#[no_mangle]
1599pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1600 guard(0, || c_int::from(read(|t| t.exists(node))))
1601}
1602
1603/// # Safety
1604/// `key` and `value` are null or NUL-terminated strings.
1605#[no_mangle]
1606pub unsafe extern "C" fn cosmic_node_set_str(
1607 node: c_int,
1608 key: *const c_char,
1609 value: *const c_char,
1610) {
1611 let (key, value) = (borrowed(key), borrowed(value));
1612 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1613}
1614
1615/// # Safety
1616/// `key` is null or a NUL-terminated string.
1617#[no_mangle]
1618pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1619 let key = borrowed(key);
1620 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1621}
1622
1623/// # Safety
1624/// `key` is null or a NUL-terminated string.
1625#[no_mangle]
1626pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1627 let key = borrowed(key);
1628 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1629}
1630
1631#[no_mangle]
1632pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1633 guard((), || edit(|t| t.clear_props(node)))
1634}
1635
1636#[no_mangle]
1637pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1638 guard(empty_str(), || {
1639 PROPS.lend(read(|t| {
1640 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1641 }))
1642 })
1643}
1644
1645#[no_mangle]
1646pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1647 guard(0, || {
1648 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1649 })
1650}
1651
1652#[no_mangle]
1653pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1654 guard(0, || {
1655 read(|t| {
1656 t.get(node)
1657 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1658 .unwrap_or(0)
1659 })
1660 })
1661}
1662
1663#[no_mangle]
1664pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1665 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1666}
1667
1668/// Unparents AND frees `child` with everything under it.
1669#[no_mangle]
1670pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1671 guard((), || edit(|t| t.remove(parent, child)))
1672}
1673
1674#[no_mangle]
1675pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1676 guard(0, || {
1677 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1678 })
1679}
1680
1681#[no_mangle]
1682pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1683 guard(0, || {
1684 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1685 })
1686}
1687
1688/// The subtree at `node` as hiccup; 0 is the root.
1689#[no_mangle]
1690pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1691 guard(empty_str(), || {
1692 DUMP.lend(read(|t| {
1693 let id = if node == 0 { t.root_id() } else { node };
1694 t.dump(id)
1695 }))
1696 })
1697}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1698
1699#[cfg(test)]
1700mod tests {
1701 use super::*;
1702
1703 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1704 let id = t.new_node(tag);
1705 assert!(t.append(parent, id));
1706 id
1707 }
1708
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1709 #[test]
1710 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1711 let mut t = Tree::default();
1712 let root = t.root();
1713 let entry = node(&mut t, root, "entry");
1714 t.set(entry, "text", Prop::Str("a".into()));
1715 let mut tree = Arc::new(t);
1716 let mut typed = HashMap::new();
1717 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1718
1719 // Rendered before the worker saw the "b".
1720 keep_typed(&mut tree, &mut typed, 1);
1721 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1722 assert_eq!(typed.len(), 1);
1723
1724 // Rendered after: the component cleared its draft, and that stands.
1725 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1726 keep_typed(&mut tree, &mut typed, 2);
1727 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1728 assert!(typed.is_empty());
1729 }
1730
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1731 #[test]
1732 fn a_zero_width_request_is_no_request() {
1733 let mut t = Tree::default();
1734 let root = t.root();
1735 let column = node(&mut t, root, "vbox");
1736 t.set(column, "width-request", Prop::Num(0.0));
1737 assert_eq!(width_request(t.get(column).unwrap()), None);
1738 t.set(column, "width-request", Prop::Num(260.0));
1739 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1740 }
1741
1742 #[test]
1743 fn a_scroll_is_named_by_its_scroll_key() {
1744 let mut t = Tree::default();
1745 let root = t.root();
1746 let list = node(&mut t, root, "scroll");
1747 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1748 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1749 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1750 }
1751
1752 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1753 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1754 // A row halfway down a long backlog, in a 600pt viewport: half the
1755 // viewport above it, less half the row.
1756 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1757 // The same row with no viewport reported yet: its own top.
1758 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1759 // A row near the top cannot be centred without scrolling above the
1760 // content, and nothing is above the content.
1761 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1762 // A row taller than the viewport is shown from its own top: there is
1763 // no middle of it to put in the middle.
1764 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1765 }
1766
1767 #[test]
1768 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1769 let mut t = Tree::default();
1770 let root = t.root();
1771 let list = node(&mut t, root, "scroll");
1772 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1773 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1774 let before = t.clone();
1775 t.set(rows[3], "scroll-here", Prop::Bool(true));
1776
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1777 // 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 ago1778 let asks = scroll_asks(&before, &t);
1779 assert_eq!(asks.len(), 1);
1780 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1781 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1782
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1783 // And it goes on asking while the row is still asking: the row may not
1784 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1785 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1786 assert_eq!(again[0].reveal, Some(rows[3]));
1787
1788 // A node asking from deeper inside a row answers with the row.
1789 t.set(rows[3], "scroll-here", Prop::Bool(false));
1790 let inner = node(&mut t, rows[1], "vbox");
1791 t.set(inner, "scroll-here", Prop::Bool(true));
1792 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1793
1794 // Nothing asking, nothing to reveal.
1795 t.set(inner, "scroll-here", Prop::Bool(false));
1796 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1797 }
1798
1799 #[test]
1800 fn a_jump_is_the_counter_moving() {
1801 let mut t = Tree::default();
1802 let root = t.root();
1803 let list = node(&mut t, root, "scroll");
1804 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1805 let before = t.clone();
1806 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1807 let asks = scroll_asks(&before, &t);
1808 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1809 }
1810}