nandi/jolt-nativepublic Fork 0
0fb017593acce485b16f9756141b599beb49dc76
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 · 1689 lines · 63.3 KBRust Blame HistoryRaw
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1//! glimmer's libcosmic backend: the retained-tree ABI, with iced reading it.
2//!
3//! The edit half is libvidya's — integer node handles, string-keyed props,
4//! events queued and polled — so `glimmer-cosmic` is `glimmer-vidya` pointed
5//! at a different object. What changes is who owns the loop.
6//!
7//! egui lets its caller drive frames; iced does not. `cosmic::app::run` takes
8//! the main thread (winit insists) and returns when the window closes. So the
9//! arrangement is inverted:
10//!
11//! * `cosmic_run` blocks the process main thread inside libcosmic.
12//! * jolt reconciles on a worker thread, mutating the arena under a mutex.
13//! Nothing it does is visible until `cosmic_tree_commit`, which snapshots the
14//! tree and wakes iced — so a reconcile half-way through a patch is never
15//! painted, and a commit with no edits behind it costs nothing.
16//! * Interactions are queued, and `cosmic_wait` blocks the worker until there
17//! is one (or `cosmic_wake`, or a timeout), so an idle window burns no CPU on
18//! either side.
19//!
20//! Every call except `cosmic_run` may come from any thread.
21
22mod tree;
23
24pub use tree::{Node, Prop, Tree};
25
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago26use std::collections::{HashMap, HashSet, VecDeque};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago27use std::ffi::{c_char, c_int};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago28use std::path::PathBuf;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago29use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago30use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
31use std::time::Duration;
32
33use cosmic::app::{Core, Task};
34use cosmic::iced::futures::channel::mpsc;
35use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago36use cosmic::iced::widget::container::Style as ContainerStyle;
37use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago38use cosmic::iced::widget::text::Wrapping;
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago39use cosmic::iced::advanced::widget as advanced;
40use cosmic::iced::advanced::widget::operation::scrollable as iced_operation;
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago41use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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 8d 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,
233}
234
235/// Two points of slack: a viewport scrolled to its end by a fractional
236/// offset is still at the end.
237const AT_END_SLACK: f32 = 2.0;
238
239fn scroll_name(n: &Node, id: i32) -> String {
240 match n.str("scroll-key") {
241 "" => format!("node-{id}"),
242 key => key.to_owned(),
243 }
244}
245
246fn scroll_id(name: &str) -> widget::Id {
247 widget::Id::new(format!("jolt-scroll-{name}"))
248}
249
250fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
251 if let Some(n) = t.get(id) {
252 f(id, n);
253 for child in &n.children {
254 walk(t, *child, f);
255 }
256 }
257}
258
259/// What a commit asks of one scroll area.
260struct ScrollAsk {
261 name: String,
262 stick: bool,
263 /// The `scroll-to-bottom` counter, and what it was in the tree before.
264 tick: Option<f64>,
265 tick_before: Option<f64>,
266 /// Not in the tree before this commit: mounted, or mounted again.
267 fresh: bool,
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago268 /// The node asking to be shown, if one is.
269 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago270}
271
272/// Every scroll area in `now`, and what changed about each since `before`.
273fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
274 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
275 walk(before, before.root_id(), &mut |id, n| {
276 if n.tag == "scroll" {
277 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
278 }
279 });
280
281 let mut asks = Vec::new();
282 walk(now, now.root_id(), &mut |id, n| {
283 if n.tag != "scroll" {
284 return;
285 }
286 let name = scroll_name(n, id);
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago287 // The ROW holding whatever asked to be shown. The row and not the
288 // asking node itself, because the row is what carries an id — see
289 // `element` — and the id is how the layout is asked where it is.
290 //
291 // This used to answer with the row's index over the row count, which
292 // is a fraction of the SCROLL RANGE rather than of the content — the
293 // two agree only when the viewport is exactly one row tall — and it
294 // assumed every row the same height besides, in a backlog that puts a
295 // one-line message next to a picture. The landing was out by up to a
296 // viewport, worst in the middle of a list.
297 //
298 // While it is asking, not only on the commit it starts: a row that is
299 // not in the tree yet when the ask arrives would otherwise never be
300 // scrolled to at all, and the ask is over in half a second.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago301 let mut reveal = None;
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago302 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago303 let mut asked = false;
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago304 walk(now, *row, &mut |_, node| {
305 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago306 });
307 if asked {
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago308 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago309 break;
310 }
311 }
312 asks.push(ScrollAsk {
313 fresh: !named_before.contains_key(&name),
314 tick_before: named_before.get(&name).copied().flatten(),
315 tick: n.num("scroll-to-bottom"),
316 stick: n.bool("stick-to-bottom") == Some(true),
317 reveal,
318 name,
319 });
320 });
321 asks
322}
323
324fn snap_to_end(name: &str) -> Task<Message> {
325 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
326}
327
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago328/// What a row that can be scrolled to is known by.
329fn here_id(node: i32) -> widget::Id {
330 widget::Id::new(format!("jolt-here-{node}"))
331}
332
333/// Where `node` sits inside the scroll area called `name`, measured, and then
334/// the scroll put there.
335///
336/// Two steps because the answer is not in the tree. The tree says which row
337/// asked; only the layout knows how far down the content it ended up, and the
338/// layout is a thing you can ask questions of exactly once a frame, through an
339/// `Operation`. So the operation reads the row's bounds and the scroll area's,
340/// works the offset out, and hands it back as a message that scrolls there.
341///
342/// The row is put in the MIDDLE of the viewport rather than against its top
343/// edge. A line answered three days ago is read with what was said around it,
344/// and a jump that pins it to the ceiling shows only what came after.
345fn reveal(name: String, node: i32) -> Task<Message> {
346 let scroll = scroll_id(&name);
347 let target = here_id(node);
348
349 struct Measure {
350 scroll: widget::Id,
351 target: widget::Id,
352 /// The scroll area: where its viewport starts, how tall it is, and
353 /// how far it is scrolled already.
354 view: Option<(f32, f32, f32)>,
355 /// The row: where it starts and how tall it is.
356 row: Option<(f32, f32)>,
357 }
358
359 impl advanced::Operation<f32> for Measure {
360 fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn advanced::Operation<f32>)) {
361 operate(self);
362 }
363
364 fn container(&mut self, id: Option<&widget::Id>, bounds: cosmic::iced::Rectangle) {
365 if id == Some(&self.target) {
366 self.row = Some((bounds.y, bounds.height));
367 }
368 }
369
370 fn scrollable(
371 &mut self,
372 id: Option<&widget::Id>,
373 bounds: cosmic::iced::Rectangle,
374 _content: cosmic::iced::Rectangle,
375 translation: cosmic::iced::Vector,
376 _state: &mut dyn iced_operation::Scrollable,
377 ) {
378 if id == Some(&self.scroll) {
379 self.view = Some((bounds.y, bounds.height, translation.y));
380 }
381 }
382
383 fn finish(&self) -> advanced::operation::Outcome<f32> {
384 match (self.view, self.row) {
385 // Both are in window coordinates, so the row's place in the
386 // content is how far it is below the viewport's top edge plus
387 // how far the viewport has already been scrolled.
388 (Some((view_y, view_h, scrolled)), Some((row_y, row_h))) => {
389 let top = row_y - view_y + scrolled;
390 let centred = top - (view_h - row_h).max(0.0) / 2.0;
391 advanced::operation::Outcome::Some(centred.max(0.0))
392 }
393 // The row is not laid out yet — it has only just been mounted,
394 // or it is not in this scroll area at all. Nothing to say, and
395 // the next commit asks again.
396 _ => advanced::operation::Outcome::None,
397 }
398 }
399 }
400
401 advanced::operate(Measure {
402 scroll,
403 target,
404 view: None,
405 row: None,
406 })
407 .map(move |y| cosmic::Action::App(Message::Revealed(name.clone(), y)))
408}
409
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago410// --- the app -----------------------------------------------------------------
411
412struct App {
413 core: Core,
414 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago415 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago416 /// What the window last wrote back into a control, by node and prop, with
417 /// the sequence number of the event that carried it to the worker.
418 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago419}
420
421#[derive(Clone, Debug)]
422enum Message {
423 Tree,
424 Quit,
425 Click(i32),
426 Toggled(i32, bool),
427 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago428 Paste(i32, String),
429 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago430 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago431 Hover(i32),
432 Unhover(i32),
433 Scrolled(i32, String, Viewport),
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago434 /// A scroll area, and how far down its content the row that asked to be
435 /// shown was measured to be.
436 Revealed(String, f32),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago437 PickImage,
438 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago439}
440
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago441/// Lay what was typed over a commit that has not caught up with it.
442///
443/// libcosmic paints a control from the tree, so a commit rendered before the
444/// worker saw the latest keystroke would put the older text back under the
445/// caret, and the next key would land on that. An entry is let go once a
446/// commit was rendered after its event: from then on the component's own
447/// state is the answer, a draft it cleared included.
448fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) {
449 typed.retain(|&(node, key), (seq, value)| {
450 let Some(n) = tree.get(node) else { return false };
451 if *seq <= settled {
452 return false;
453 }
454 if n.props.get(key) != Some(value) {
455 Arc::make_mut(tree).set(node, key, value.clone());
456 }
457 true
458 });
459}
460
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago461impl App {
462 /// A widget does not own its value: the new state goes into the arena and
463 /// 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 ago464 /// working control, and its next render is what settles it. Then the event
465 /// goes to the worker, and what was written is held over any commit
466 /// rendered before the worker saw it.
467 fn write_back(&mut self, node: i32, key: &'static str, value: Prop, event: &'static str, text: String, num: f64) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago468 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 ago469 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
470 let seq = post_seq(node, event, text, num);
471 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago472 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago473
474 /// Take the committed tree, and move every scroll area to where it should
475 /// be now that it has changed.
476 ///
477 /// A snap is relative, so a list snapped to its end stays at its end as
478 /// rows arrive under it, until the reader scrolls away.
479 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago480 let (committed, settled) = {
481 let c = lock(&COMMITTED);
482 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
483 };
484 let before = std::mem::replace(&mut self.tree, committed);
485 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago486 let mut tasks = Vec::new();
487 let mut live = HashSet::new();
488 for ask in scroll_asks(&before, &self.tree) {
489 live.insert(ask.name.clone());
490 let memo = self
491 .scrolls
492 .entry(ask.name.clone())
493 .or_insert(ScrollMemo {
494 at_end: ask.stick,
495 offset_y: 0.0,
496 });
497 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago498 if let Some(node) = ask.reveal {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago499 memo.at_end = false;
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago500 tasks.push(reveal(ask.name.clone(), node));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago501 } else if jumped || (ask.stick && memo.at_end) {
502 memo.at_end = true;
503 tasks.push(snap_to_end(&ask.name));
504 } else if ask.fresh {
505 tasks.push(iced_scrollable::scroll_to(
506 scroll_id(&ask.name),
507 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
508 ));
509 }
510 }
511 // A list that was never scrolled keeps no memo worth the space; one
512 // that was keeps its place for when it comes back.
513 self.scrolls
514 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
515 Task::batch(tasks)
516 }
517
518 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
519 let y = viewport.absolute_offset().y;
520 let room = viewport.content_bounds().height - viewport.bounds().height;
521 let at_end = room - y <= AT_END_SLACK;
522 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
523 at_end,
524 offset_y: y,
525 });
526 let was = memo.at_end;
527 memo.at_end = at_end;
528 memo.offset_y = y;
529 // "end" or "away", the strings libvidya emits: frq's handler compares
530 // against "end".
531 if was != at_end {
532 let place = if at_end { "end" } else { "away" };
533 post(node, "change", place.to_owned(), 0.0);
534 }
535 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago536}
537
538impl cosmic::Application for App {
539 type Executor = cosmic::executor::Default;
540 type Flags = String;
541 type Message = Message;
542 const APP_ID: &'static str = "dev.jolt.Glimmer";
543
544 fn core(&self) -> &Core {
545 &self.core
546 }
547
548 fn core_mut(&mut self) -> &mut Core {
549 &mut self.core
550 }
551
552 fn init(core: Core, title: String) -> (Self, Task<Message>) {
553 let mut app = App {
554 core,
555 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago556 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago557 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago558 };
559 // libcosmic's `wayland` feature brings `multi-window` with it, which
560 // makes a window title a per-window thing.
561 app.set_header_title(title.clone());
562 let task = match app.core.main_window_id() {
563 Some(id) => app.set_window_title(title, id),
564 None => Task::none(),
565 };
566 (app, task)
567 }
568
569 fn subscription(&self) -> Subscription<Message> {
570 Subscription::run(wakes)
571 }
572
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago573 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
574 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
575 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
576 }
577
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago578 fn update(&mut self, message: Message) -> Task<Message> {
579 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago580 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago581 Message::Quit => return cosmic::iced::exit(),
582 Message::Click(node) => post(node, "click", String::new(), 0.0),
583 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago584 let num = f64::from(u8::from(on));
585 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago586 }
587 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago588 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
589 }
590 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
591 // clipboard holding a picture has none, so the field comes back as
592 // it was. That is the paste worth reporting: the picture is read
593 // here, where the clipboard is, and `paste-empty` goes to the
594 // worker, which collects it with `cosmic_clipboard_image_png`.
595 Message::Paste(node, text) => {
596 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
597 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
598 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
599 }
600 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
601 }
602 Message::PastedPicture(node, png) => {
603 *lock(&CLIPBOARD_PNG) = png;
604 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago605 }
606 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago607 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
608 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
609 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago610 // The measurement came back: put the scroll where it says. The
611 // memo is written too, so a list that is unmounted and comes back
612 // opens where the jump left it rather than where it was before.
613 Message::Revealed(name, y) => {
614 let id = scroll_id(&name);
615 if let Some(memo) = self.scrolls.get_mut(&name) {
616 memo.at_end = false;
617 memo.offset_y = y;
618 }
619 return iced_scrollable::scroll_to(id, AbsoluteOffset { x: None, y: Some(y) });
620 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago621 // The desktop's own chooser, through the portal, on libcosmic's
622 // executor: it is a D-Bus round trip, and the window keeps
623 // painting while it is open.
624 Message::PickImage => {
625 return Task::perform(
626 async {
627 rfd::AsyncFileDialog::new()
628 .set_title("Choose a picture")
629 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
630 .pick_file()
631 .await
632 .map(|file| file.path().to_path_buf())
633 },
634 |path| cosmic::Action::App(Message::Picked(path)),
635 );
636 }
637 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago638 }
639 Task::none()
640 }
641
642 fn view(&self) -> Element<'_, Message> {
643 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago644 let root = element(tree, tree.root_id(), true, false);
645 // A dialog that asked not to be modal, put up here rather than handed
646 // to `dialog` below. It is the same widget in the same place — a
647 // `popover` centres it exactly as `cosmic::app` does — and the whole
648 // of the difference is that this one is not told to intercept the
649 // pointer. That matters to anything the pointer opened: a modal
650 // popover hands the window underneath it a cursor that is
651 // `Unavailable`, so a face that opened a dialog on hover never hears
652 // the pointer leave, and what it opened can never close itself.
653 //
654 // The popover is here whether or not there is anything in it, which
655 // `cosmic::app` says of its own in one line and which this learned
656 // the long way: iced keeps a widget's state by where it sits in the
657 // tree, so a wrapper that comes and goes rebuilds everything under
658 // it — and what "everything" holds is the scroll positions. Wrapping
659 // only when a dialog appeared meant resting the pointer on a face
660 // jumped the conversation behind it.
661 let mut popover = widget::popover(root);
662 if let Some(id) = find_dialog(tree, false) {
663 // The dialog reports its own pointer, on the same two events a
664 // face or a pill reports theirs. Without it a dialog the pointer
665 // opened can only be read at arm's length: the client is told the
666 // pointer left what opened it and never told it arrived here, so
667 // the one way to keep it up is not to move — and everything in it
668 // is out of reach.
669 let popup = widget::mouse_area(dialog_of(tree, id))
670 .on_enter(Message::Hover(id))
671 .on_exit(Message::Unhover(id));
672 popover = popover.popup(popup);
673 }
674 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago675 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago676
677 /// The MODAL dialog the tree is carrying, if it is carrying one.
678 ///
679 /// A client says there is one by putting a `dialog` node in the tree and
680 /// says there is not by leaving it out — the same way it says anything
681 /// else. What comes back is libcosmic's own dialog: centred, over a
682 /// dimmed window, and closed by the buttons the client hung on it.
683 ///
684 /// A dialog that says `modal false` does not come back here. This hook is
685 /// the modal one whether the client wants it or not — `cosmic::app` wraps
686 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
687 /// that kind up itself. See `dialog_of`.
688 fn dialog(&self) -> Option<Element<'_, Message>> {
689 let tree = &*self.tree;
690 let id = find_dialog(tree, true)?;
691 Some(dialog_of(tree, id))
692 }
693}
694
695/// The first `dialog` node in the tree whose modality is `modal`.
696///
697/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
698/// not, which is the shape everything else here takes — a prop left out is
699/// the ordinary answer.
700fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
701 let mut found = None;
702 walk(t, t.root_id(), &mut |id, n| {
703 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
704 found = Some(id);
705 }
706 });
707 found
708}
709
710/// One `dialog` node as libcosmic's dialog.
711///
712/// `label` is its heading and `body` the line under it. Children are its
713/// controls, in order, except that a child carrying `slot` "primary" or
714/// "secondary" becomes that action instead — which is where libcosmic puts
715/// the buttons, at the foot and to the right.
716fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
717 let Some(n) = t.get(id) else {
718 return widget::Space::new().width(0).height(0).into();
719 };
720 let mut d = widget::dialog();
721 if !n.label().is_empty() {
722 d = d.title(n.label().to_owned());
723 }
724 if !n.str("body").is_empty() {
725 d = d.body(n.str("body").to_owned());
726 }
727 if let Some(w) = n.num("max-width") {
728 d = d.max_width(w as f32);
729 }
730 for child in &n.children {
731 let Some(c) = t.get(*child) else { continue };
732 let el = element(t, *child, true, false);
733 d = match c.str("slot") {
734 "primary" => d.primary_action(el),
735 "secondary" => d.secondary_action(el),
736 _ => d.control(el),
737 };
738 }
739 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago740}
741
742// --- props into layout -----------------------------------------------------------
743
744/// `margin` all round, with `margin-top` and its siblings overriding a side.
745fn margins(n: &Node) -> Padding {
746 let all = n.num("margin").unwrap_or(0.0) as f32;
747 let side = |key| n.num(key).map_or(all, |v| v as f32);
748 Padding {
749 top: side("margin-top"),
750 right: side("margin-right"),
751 bottom: side("margin-bottom"),
752 left: side("margin-left"),
753 }
754}
755
756/// A width the client asked for. Zero is the client saying "none": frq writes
757/// `:width-request 0` on its message column whenever the people panel is shut,
758/// and taken literally that is a backlog laid out zero points wide.
759fn width_request(n: &Node) -> Option<f32> {
760 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
761}
762
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago763/// `align`, or `default` where it is not set. A row centres its children on
764/// the cross axis by default — a label beside a button otherwise sits against
765/// the top of the button — and a column starts them at the left.
766fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago767 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago768 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago769 "center" => Alignment::Center,
770 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago771 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago772 }
773}
774
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago775fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
776 cosmic::theme::Container::custom(move |_| ContainerStyle {
777 background: Some(Background::Color(color)),
778 border: Border {
779 radius: radius.into(),
780 ..Border::default()
781 },
782 text_color: Some(Color::WHITE),
783 ..ContainerStyle::default()
784 })
785}
786
787/// A colour for somebody, from their name, so the same person is the same
788/// colour everywhere they appear.
789fn name_colour(name: &str) -> Color {
790 const PALETTE: [(f32, f32, f32); 8] = [
791 (0.83, 0.33, 0.33),
792 (0.85, 0.55, 0.20),
793 (0.62, 0.62, 0.18),
794 (0.30, 0.65, 0.35),
795 (0.20, 0.62, 0.62),
796 (0.30, 0.50, 0.85),
797 (0.55, 0.40, 0.85),
798 (0.80, 0.35, 0.65),
799 ];
800 let hash = name
801 .bytes()
802 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
803 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
804 Color::from_rgb(r, g, b)
805}
806
807/// A picture that answers a click, with the pointer saying so.
808fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
809 if !enabled {
810 return el;
811 }
812 widget::mouse_area(el)
813 .on_press(Message::Click(id))
814 .interaction(cosmic::iced::mouse::Interaction::Pointer)
815 .into()
816}
817
818fn picture(path: &str) -> Option<widget::image::Handle> {
819 (!path.is_empty() && std::path::Path::new(path).exists())
820 .then(|| widget::image::Handle::from_path(path))
821}
822
823// --- the tree into widgets ---------------------------------------------------------
824
825/// One node and everything under it, as widgets.
826///
827/// `enabled` is inherited: an insensitive container takes its whole subtree out
828/// of interaction. `in_row` is whether the parent lays its children out across:
829/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
830/// column in a column takes the width and a column in a row does not take the
831/// row's slack unless it says `fill-height`.
832fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago833 let Some(n) = t.get(id) else {
834 return Column::new().into();
835 };
836 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago837 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago838 // glimmer-jvui's theme spacing, where the client does not say: a list of
839 // cards with nothing between them reads as one slab.
840 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago841 let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago842
843 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago844 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago845 .width(Length::Fill)
846 .height(Length::Fill)
847 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago848 // Sizes are set only where something asked for one. iced's rows and
849 // columns take `Fill` on an axis from any child that fills it, which is
850 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
851 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
852 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago853 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago854 let across = n.str("orientation") == "horizontal";
855 if across {
856 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago857 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago858 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago859 .align_y(alignment(n, Alignment::Center));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago860 // A row fills the width it is in only when it or something in
861 // it asks to; otherwise a line of buttons would spread out.
862 match width_request(n) {
863 Some(w) => row = row.width(w),
864 None if fill_height => row = row.width(Length::Fill),
865 None => {}
866 }
867 if fill_height {
868 row = row.height(Length::Fill);
869 }
870 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago871 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago872 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago873 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago874 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago875 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago876 match width_request(n) {
877 Some(w) => column = column.width(w),
878 None if fill_height || !in_row => column = column.width(Length::Fill),
879 None => {}
880 }
881 if fill_height {
882 column = column.height(Length::Fill);
883 }
884 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago885 }
886 }
887 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago888 let column = Column::with_children(children(false))
889 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago890 .padding(24)
891 .width(Length::Fill);
892 let mut inner = widget::container(column).width(Length::Fill);
893 if let Some(max) = n.num("max-width") {
894 inner = inner.max_width(max as f32);
895 }
896 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago897 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago898 .height(Length::Fill)
899 .into()
900 }
901 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago902 let mut column = Column::new().spacing(n.num("spacing").unwrap_or(8.0) as f32);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago903 if n.tag == "frame" && !n.label().is_empty() {
904 column = column.push(widget::text::heading(n.label()));
905 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago906 let card = widget::container(column.extend(children(false)))
907 .padding(12)
908 .class(cosmic::theme::Container::Card);
909 match width_request(n) {
910 Some(w) => card.width(w).into(),
911 None if !in_row => card.width(Length::Fill).into(),
912 None => card.into(),
913 }
914 }
915 // Always fills both ways: a viewport that only fills its width asks its
916 // column for no height, and is given none. The content is held to its
917 // own height, since iced will not scroll content that fills the axis it
918 // scrolls along.
919 "scroll" => {
920 let name = scroll_name(n, id);
921 let content = Column::with_children(children(false))
922 .spacing(spacing)
923 .width(Length::Fill)
924 .height(Length::Shrink);
925 widget::scrollable(content)
926 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago927 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago928 .height(Length::Fill)
929 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago930 .into()
931 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago932 // Word wrapping that falls back to breaking inside a word: a URL is one
933 // word, and it otherwise runs straight past the edge of its column.
934 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
935 .wrapping(Wrapping::WordOrGlyph)
936 .into(),
937 "label" => widget::text::body(n.label())
938 .wrapping(Wrapping::WordOrGlyph)
939 .into(),
940 "title" => widget::text::title3(n.label())
941 .wrapping(Wrapping::WordOrGlyph)
942 .into(),
943 "title-2" => widget::text::title4(n.label())
944 .wrapping(Wrapping::WordOrGlyph)
945 .into(),
946 "dim-label" => widget::text::caption(n.label())
947 .wrapping(Wrapping::WordOrGlyph)
948 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago949 "button" => {
950 let button = match n.str("kind") {
951 "primary" => widget::button::suggested(n.label()),
952 "destructive" => widget::button::destructive(n.label()),
953 _ => widget::button::standard(n.label()),
954 };
955 button
956 .on_press_maybe(enabled.then_some(Message::Click(id)))
957 .into()
958 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago959 "link" => widget::button::link(n.label().to_owned())
960 .on_press_maybe(enabled.then_some(Message::Click(id)))
961 .into(),
962 // A dot that says whether the thing is live, and the words beside it.
963 "status" => {
964 let colour = if n.bool("live") == Some(true) {
965 Color::from_rgb(0.30, 0.72, 0.40)
966 } else {
967 Color::from_rgb(0.55, 0.55, 0.55)
968 };
969 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
970 Row::new()
971 .spacing(6)
972 .align_y(Alignment::Center)
973 .push(dot)
974 .push(widget::text::caption(n.label()))
975 .into()
976 }
977 "spinner" => {
978 let mut row = Row::new()
979 .spacing(8)
980 .align_y(Alignment::Center)
981 .push(widget::progress_bar::indeterminate_circular().size(16.0));
982 if !n.label().is_empty() {
983 row = row.push(widget::text::caption(n.label()));
984 }
985 row.into()
986 }
987 "emoji" => {
988 let glyph = match n.str("emoji") {
989 "" => n.label(),
990 e => e,
991 };
992 widget::text(glyph.to_owned())
993 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago994 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago995 .into()
996 }
997 // A round picture, or the initial on a colour from the name: most
998 // people in most rooms have no picture, so the initial IS the avatar.
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago999 //
1000 // And the three things a face is for besides being looked at. It
1001 // painted as a picture and nothing else until now: a client that
1002 // asked a face to answer a click, to report the pointer arriving, or
1003 // to carry a card under it was handed a portrait that did none of
1004 // them — so the profile behind every avatar in the window was
1005 // unreachable, and the hover card written for it never appeared.
1006 // Those are the same three things `reaction` below does, so they are
1007 // done the same way: `mouse_area` for the press and the two edges of
1008 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1009 "avatar" => {
1010 let size = n.num("size").unwrap_or(32.0) as f32;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1011 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1012 Some(handle) => widget::image(handle)
1013 .width(size)
1014 .height(size)
1015 .content_fit(ContentFit::Cover)
1016 .border_radius(size / 2.0)
1017 .into(),
1018 None => {
1019 let initial: String = n
1020 .label()
1021 .trim_start_matches(|c: char| !c.is_alphanumeric())
1022 .chars()
1023 .next()
1024 .map(|c| c.to_uppercase().collect())
1025 .unwrap_or_default();
1026 widget::container(widget::text(initial).size(size * 0.45))
1027 .center(Length::Fixed(size))
1028 .class(filled(name_colour(n.label()), size / 2.0))
1029 .into()
1030 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1031 };
1032 // The hover is reported whether or not the face is enabled: it
1033 // says where the pointer is, which is true of an insensitive
1034 // picture too. The press is not — an insensitive subtree is out
1035 // of interaction, which is what `enabled` means here.
1036 let mut area = widget::mouse_area(face)
1037 .on_enter(Message::Hover(id))
1038 .on_exit(Message::Unhover(id));
1039 if enabled {
1040 area = area
1041 .on_press(Message::Click(id))
1042 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1043 }
1044 if n.children.is_empty() {
1045 area.into()
1046 } else {
1047 widget::tooltip(
1048 area,
1049 Column::with_children(children(false)).spacing(4),
1050 widget::tooltip::Position::Bottom,
1051 )
1052 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1053 }
1054 }
1055 // A pill: an emoji, how many people, and whether you are one of them.
1056 // What the client hangs under it is its hover card, shown while the
1057 // pointer is on the pill.
1058 "reaction" => {
1059 let glyph = match n.str("emoji") {
1060 "" => n.label(),
1061 e => e,
1062 };
1063 let size = n.num("size").unwrap_or(16.0) as f32;
1064 let mut content = Row::new()
1065 .spacing(4)
1066 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1067 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1068 let count = n.num("count").unwrap_or(0.0);
1069 if count > 0.0 {
1070 content = content.push(widget::text::caption(format!("{count}")));
1071 }
1072 let class = if n.bool("mine") == Some(true) {
1073 widget::button::ButtonClass::Suggested
1074 } else {
1075 widget::button::ButtonClass::Standard
1076 };
1077 let pill = widget::button::custom(content)
1078 .padding([2, 8])
1079 .class(class)
1080 .on_press_maybe(enabled.then_some(Message::Click(id)));
1081 let pill = widget::mouse_area(pill)
1082 .on_enter(Message::Hover(id))
1083 .on_exit(Message::Unhover(id));
1084 if n.children.is_empty() {
1085 pill.into()
1086 } else {
1087 widget::tooltip(
1088 pill,
1089 Column::with_children(children(false)).spacing(4),
1090 widget::tooltip::Position::Bottom,
1091 )
1092 .into()
1093 }
1094 }
1095 // One tag for both kinds of picture, as in libvidya. `feed` is live
1096 // pixels pushed under a name, which nothing pushes here yet, so it
1097 // holds the slot the layout gave it.
1098 "image" => {
1099 let max_w = n.num("max-width").map(|v| v as f32);
1100 let max_h = n.num("max-height").map(|v| v as f32);
1101 if !n.str("feed").is_empty() {
1102 let w = max_w.unwrap_or(160.0);
1103 let h = max_h.unwrap_or(w * 0.75);
1104 widget::container(widget::text::caption("video"))
1105 .center_x(Length::Fixed(w))
1106 .center_y(Length::Fixed(h))
1107 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1108 .into()
1109 } else if let Some(handle) = picture(n.str("src")) {
1110 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1111 if n.bool("fit") == Some(true) {
1112 image = image.width(Length::Fill).height(Length::Fill);
1113 } else if let Some(size) = n.num("size") {
1114 image = image.width(size as f32).height(size as f32);
1115 }
1116 let mut bounded = widget::container(image);
1117 if let Some(w) = max_w {
1118 bounded = bounded.max_width(w);
1119 }
1120 if let Some(h) = max_h {
1121 bounded = bounded.max_height(h);
1122 }
1123 clickable(bounded.into(), id, enabled)
1124 } else {
1125 widget::Space::new().width(0).height(0).into()
1126 }
1127 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1128 "checkbutton" => {
1129 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1130 if enabled {
1131 check = check.on_toggle(move |on| Message::Toggled(id, on));
1132 }
1133 check.into()
1134 }
1135 "entry" => {
1136 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1137 if enabled {
1138 entry = entry
1139 .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 ago1140 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1141 .on_submit(move |_| Message::Activate(id));
1142 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1143 let width = match width_request(n) {
1144 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1145 _ => Length::Fill,
1146 };
1147 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1148 }
1149 "separator" => widget::divider::horizontal::default().into(),
1150 "spacer" => {
1151 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1152 if n.str("expand").is_empty() {
1153 widget::Space::new().width(size).height(size).into()
1154 } else {
1155 widget::Space::new().width(Length::Fill).height(size).into()
1156 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1157 }
1158 "progress" => {
1159 let bar =
1160 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1161 if n.label().is_empty() {
1162 bar.into()
1163 } else {
1164 Column::new()
1165 .spacing(4)
1166 .push(widget::text::caption(n.label()))
1167 .push(bar)
1168 .into()
1169 }
1170 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1171 // The one node that is not painted where it stands. libcosmic puts a
1172 // dialog up itself, centred over the window and dimming what is
1173 // behind it — `Application::dialog` is the hook, and it is asked for
1174 // one separately from `view`. So the tree carries the dialog wherever
1175 // the client found it convenient to write it, `App::dialog` goes and
1176 // finds it there, and this leaves nothing behind in the layout. A
1177 // node rendered in both places would be painted twice.
1178 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1179 // Kept rather than refused, as in libvidya: a tag this backend has not
1180 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1181 _ => Column::with_children(children(false))
1182 .spacing(spacing)
1183 .padding(margins(n))
1184 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1185 };
1186
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago1187 // Every row of a scroll area carries an id, so the operation in `reveal`
1188 // can find out where one of them actually is.
1189 //
1190 // Every row, and not only the row that is asking to be shown. iced tells
1191 // two widgets apart by their state's type, and a wrapper with no state of
1192 // its own is indistinguishable from the column it wraps — so a wrapper
1193 // that came and went as a row became the target would have the old tree
1194 // reused for the new shape, and the state under it would be read as
1195 // something it is not. That is not a wrong answer, it is a panic in the
1196 // middle of a layout. Whether a node is a row of a scroll area is a
1197 // question about where it sits, and where it sits does not change under
1198 // it.
1199 let el = match t.get(n.parent) {
1200 Some(parent) if parent.tag == "scroll" => widget::id_container(el, here_id(id)).into(),
1201 _ => el,
1202 };
1203
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1204 // The containers and the entry size themselves above; anything else asked
1205 // for a width gets it from a wrapper.
1206 match (n.tag.as_str(), width_request(n)) {
1207 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1208 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1209 }
1210}
1211
1212// --- the C ABI: the loop -------------------------------------------------------
1213
1214static TITLE: Mutex<String> = Mutex::new(String::new());
1215
1216/// The window's title, read when `cosmic_run` opens it. A call of its own
1217/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1218/// `cosmic_run` has to be one.
1219///
1220/// # Safety
1221/// `title` is null or a NUL-terminated string.
1222#[no_mangle]
1223pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1224 let title = borrowed(title);
1225 guard((), || *lock(&TITLE) = title)
1226}
1227
1228/// Open the window and run libcosmic until it closes. Blocks; call it on the
1229/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1230///
1231/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1232/// in this process — winit's event loop cannot be made twice.
1233#[no_mangle]
1234pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1235 let status = guard(1, || {
1236 let title = lock(&TITLE).clone();
1237 if RAN.swap(true, SeqCst) {
1238 log::error!("jolt-cosmic: a window already ran in this process");
1239 return 2;
1240 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1241 // The size asked for, until libcosmic reports the one it got.
1242 WINDOW_W.store(width.max(1) as u32, SeqCst);
1243 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1244 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1245 let mut settings = cosmic::app::Settings::default().size(size);
1246 match mode {
1247 1 => settings = settings.theme(cosmic::Theme::dark()),
1248 2 => settings = settings.theme(cosmic::Theme::light()),
1249 _ => {}
1250 }
1251 match cosmic::app::run::<App>(settings, title) {
1252 Ok(()) => 0,
1253 Err(err) => {
1254 eprintln!("jolt-cosmic: {err}");
1255 1
1256 }
1257 }
1258 });
1259 // Outside the guard, so a panic in libcosmic still releases the worker.
1260 *lock(&TO_APP) = None;
1261 CLOSED.store(true, SeqCst);
1262 BELL.notify_all();
1263 status
1264}
1265
1266/// 1 once `cosmic_run` has returned.
1267#[no_mangle]
1268pub extern "C" fn cosmic_should_close() -> c_int {
1269 c_int::from(CLOSED.load(SeqCst))
1270}
1271
1272/// Close the window. Asked before the window exists, it closes on opening.
1273#[no_mangle]
1274pub extern "C" fn cosmic_quit() {
1275 guard((), || {
1276 QUIT_ASKED.store(true, SeqCst);
1277 tell_app(Wake::Quit);
1278 })
1279}
1280
1281/// Publish the edits since the last commit. Answers 1 when there were any.
1282#[no_mangle]
1283pub extern "C" fn cosmic_tree_commit() -> c_int {
1284 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1285 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1286 let snapshot = {
1287 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1288 // A pass that only settled events still publishes, so a control
1289 // holding typed text over an older commit lets go of it.
1290 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1291 return 0;
1292 }
1293 e.dirty = false;
1294 Arc::new(e.tree.clone())
1295 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1296 {
1297 let mut committed = lock(&COMMITTED);
1298 *committed = snapshot;
1299 COMMITTED_SETTLED.store(settled, SeqCst);
1300 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1301 tell_app(Wake::Tree);
1302 1
1303 })
1304}
1305
1306/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1307/// closing. Answers 1 when an event is waiting.
1308#[no_mangle]
1309pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1310 guard(0, || {
1311 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1312 let (mut inbox, _) = BELL
1313 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1314 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1315 })
1316 .unwrap_or_else(|poisoned| poisoned.into_inner());
1317 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1318 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1319 c_int::from(!inbox.queue.is_empty())
1320 })
1321}
1322
1323/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1324#[no_mangle]
1325pub extern "C" fn cosmic_wake() {
1326 guard((), || {
1327 lock(&INBOX).woken = true;
1328 BELL.notify_all();
1329 })
1330}
1331
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1332// --- the C ABI: the window and the desktop -----------------------------------------
1333
1334/// The window's width in points; the size asked for until it has opened.
1335#[no_mangle]
1336pub extern "C" fn cosmic_window_width() -> c_int {
1337 WINDOW_W.load(SeqCst) as c_int
1338}
1339
1340#[no_mangle]
1341pub extern "C" fn cosmic_window_height() -> c_int {
1342 WINDOW_H.load(SeqCst) as c_int
1343}
1344
1345/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1346/// there is no window to ask from; the choice arrives through
1347/// `cosmic_picked_image`.
1348#[no_mangle]
1349pub extern "C" fn cosmic_pick_image() -> c_int {
1350 guard(0, || {
1351 if lock(&TO_APP).is_none() {
1352 return 0;
1353 }
1354 *lock(&PICK) = Pick::Open;
1355 tell_app(Wake::PickImage);
1356 1
1357 })
1358}
1359
1360/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1361/// was chosen since the last call; 0 while the chooser is open, after it was
1362/// cancelled, or when the picture could not be read.
1363///
1364/// # Safety
1365/// `path` is null or a NUL-terminated string.
1366#[no_mangle]
1367pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1368 let path = borrowed(path);
1369 guard(0, || {
1370 let chosen = {
1371 let mut pick = lock(&PICK);
1372 match std::mem::replace(&mut *pick, Pick::Idle) {
1373 Pick::Chosen(chosen) => chosen,
1374 other => {
1375 *pick = other;
1376 return 0;
1377 }
1378 }
1379 };
1380 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1381 Ok(()) => 1,
1382 Err(err) => {
1383 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1384 0
1385 }
1386 }
1387 })
1388}
1389
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1390/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1391/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1392/// already taken, or when the file could not be written.
1393///
1394/// # Safety
1395/// `path` is null or a NUL-terminated string.
1396#[no_mangle]
1397pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1398 let path = borrowed(path);
1399 guard(0, || {
1400 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1401 return 0;
1402 };
1403 match std::fs::write(&*path, png) {
1404 Ok(()) => 1,
1405 Err(err) => {
1406 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1407 0
1408 }
1409 }
1410 })
1411}
1412
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1413// --- the C ABI: events -----------------------------------------------------------
1414
1415static EVENT_NAME: Scratch = Scratch::new();
1416static EVENT_TEXT: Scratch = Scratch::new();
1417
1418/// Dequeue one event; 1 while there was one. The accessors describe it.
1419#[no_mangle]
1420pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1421 guard(0, || {
1422 let mut inbox = lock(&INBOX);
1423 let next = inbox.queue.pop_front();
1424 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1425 if let Some(e) = &next {
1426 inbox.taken = e.seq;
1427 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1428 inbox.current = next;
1429 c_int::from(got)
1430 })
1431}
1432
1433#[no_mangle]
1434pub extern "C" fn cosmic_tree_event_node() -> c_int {
1435 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1436}
1437
1438#[no_mangle]
1439pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1440 guard(empty_str(), || {
1441 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1442 })
1443}
1444
1445#[no_mangle]
1446pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1447 guard(empty_str(), || {
1448 let text = lock(&INBOX)
1449 .current
1450 .as_ref()
1451 .map(|e| e.text.clone())
1452 .unwrap_or_default();
1453 EVENT_TEXT.lend(text)
1454 })
1455}
1456
1457#[no_mangle]
1458pub extern "C" fn cosmic_tree_event_num() -> f64 {
1459 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1460}
1461
1462// --- the C ABI: nodes --------------------------------------------------------------
1463
1464static PROPS: Scratch = Scratch::new();
1465static DUMP: Scratch = Scratch::new();
1466
1467#[no_mangle]
1468pub extern "C" fn cosmic_tree_root() -> c_int {
1469 guard(0, || edit(Tree::root))
1470}
1471
1472/// # Safety
1473/// `tag` is null or a NUL-terminated string.
1474#[no_mangle]
1475pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1476 let tag = borrowed(tag);
1477 guard(0, || edit(|t| t.new_node(&tag)))
1478}
1479
1480#[no_mangle]
1481pub extern "C" fn cosmic_node_free(node: c_int) {
1482 guard((), || edit(|t| t.free(node)))
1483}
1484
1485#[no_mangle]
1486pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1487 guard(0, || c_int::from(read(|t| t.exists(node))))
1488}
1489
1490/// # Safety
1491/// `key` and `value` are null or NUL-terminated strings.
1492#[no_mangle]
1493pub unsafe extern "C" fn cosmic_node_set_str(
1494 node: c_int,
1495 key: *const c_char,
1496 value: *const c_char,
1497) {
1498 let (key, value) = (borrowed(key), borrowed(value));
1499 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1500}
1501
1502/// # Safety
1503/// `key` is null or a NUL-terminated string.
1504#[no_mangle]
1505pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1506 let key = borrowed(key);
1507 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1508}
1509
1510/// # Safety
1511/// `key` is null or a NUL-terminated string.
1512#[no_mangle]
1513pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1514 let key = borrowed(key);
1515 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1516}
1517
1518#[no_mangle]
1519pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1520 guard((), || edit(|t| t.clear_props(node)))
1521}
1522
1523#[no_mangle]
1524pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1525 guard(empty_str(), || {
1526 PROPS.lend(read(|t| {
1527 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1528 }))
1529 })
1530}
1531
1532#[no_mangle]
1533pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1534 guard(0, || {
1535 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1536 })
1537}
1538
1539#[no_mangle]
1540pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1541 guard(0, || {
1542 read(|t| {
1543 t.get(node)
1544 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1545 .unwrap_or(0)
1546 })
1547 })
1548}
1549
1550#[no_mangle]
1551pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1552 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1553}
1554
1555/// Unparents AND frees `child` with everything under it.
1556#[no_mangle]
1557pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1558 guard((), || edit(|t| t.remove(parent, child)))
1559}
1560
1561#[no_mangle]
1562pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1563 guard(0, || {
1564 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1565 })
1566}
1567
1568#[no_mangle]
1569pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1570 guard(0, || {
1571 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1572 })
1573}
1574
1575/// The subtree at `node` as hiccup; 0 is the root.
1576#[no_mangle]
1577pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1578 guard(empty_str(), || {
1579 DUMP.lend(read(|t| {
1580 let id = if node == 0 { t.root_id() } else { node };
1581 t.dump(id)
1582 }))
1583 })
1584}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1585
1586#[cfg(test)]
1587mod tests {
1588 use super::*;
1589
1590 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1591 let id = t.new_node(tag);
1592 assert!(t.append(parent, id));
1593 id
1594 }
1595
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1596 #[test]
1597 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1598 let mut t = Tree::default();
1599 let root = t.root();
1600 let entry = node(&mut t, root, "entry");
1601 t.set(entry, "text", Prop::Str("a".into()));
1602 let mut tree = Arc::new(t);
1603 let mut typed = HashMap::new();
1604 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1605
1606 // Rendered before the worker saw the "b".
1607 keep_typed(&mut tree, &mut typed, 1);
1608 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1609 assert_eq!(typed.len(), 1);
1610
1611 // Rendered after: the component cleared its draft, and that stands.
1612 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1613 keep_typed(&mut tree, &mut typed, 2);
1614 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1615 assert!(typed.is_empty());
1616 }
1617
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1618 #[test]
1619 fn a_zero_width_request_is_no_request() {
1620 let mut t = Tree::default();
1621 let root = t.root();
1622 let column = node(&mut t, root, "vbox");
1623 t.set(column, "width-request", Prop::Num(0.0));
1624 assert_eq!(width_request(t.get(column).unwrap()), None);
1625 t.set(column, "width-request", Prop::Num(260.0));
1626 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1627 }
1628
1629 #[test]
1630 fn a_scroll_is_named_by_its_scroll_key() {
1631 let mut t = Tree::default();
1632 let root = t.root();
1633 let list = node(&mut t, root, "scroll");
1634 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1635 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1636 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1637 }
1638
1639 #[test]
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago1640 fn scroll_here_asks_with_the_row_itself_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1641 let mut t = Tree::default();
1642 let root = t.root();
1643 let list = node(&mut t, root, "scroll");
1644 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1645 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1646 let before = t.clone();
1647 t.set(rows[3], "scroll-here", Prop::Bool(true));
1648
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago1649 // The row, not a guess at where it sits: only the layout knows that,
1650 // and `reveal` is what goes and asks it. Here the row and the node
1651 // asking are the same; a message row asks from inside itself, and the
1652 // answer is still the row, since the row is what carries an id.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1653 let asks = scroll_asks(&before, &t);
1654 assert_eq!(asks.len(), 1);
1655 assert!(!asks[0].fresh);
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago1656 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1657
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago1658 // And it asks again while the row is still asking. It used to ask only
1659 // on the commit the prop arrived, which meant a row not laid out yet —
1660 // a conversation the client has only just switched to — was never
1661 // scrolled to at all.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1662 let again = scroll_asks(&t, &t);
Scroll to the row that asked, where it actually is 0fb0175 nandi 7d ago1663 assert_eq!(again[0].reveal, Some(rows[3]));
1664
1665 // Nothing asking, nothing to reveal.
1666 t.set(rows[3], "scroll-here", Prop::Bool(false));
1667 let quiet = scroll_asks(&t, &t);
1668 assert_eq!(quiet[0].reveal, None);
1669
1670 // And a node asking from deeper inside a row still answers with the
1671 // row: that is the thing the layout can be asked about.
1672 let inner = node(&mut t, rows[1], "vbox");
1673 t.set(inner, "scroll-here", Prop::Bool(true));
1674 let deep = scroll_asks(&t, &t);
1675 assert_eq!(deep[0].reveal, Some(rows[1]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1676 }
1677
1678 #[test]
1679 fn a_jump_is_the_counter_moving() {
1680 let mut t = Tree::default();
1681 let root = t.root();
1682 let list = node(&mut t, root, "scroll");
1683 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1684 let before = t.clone();
1685 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1686 let asks = scroll_asks(&before, &t);
1687 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1688 }
1689}