nandi/jolt-nativepublic Fork 0
0ab003e6fe9419234eb25cfd91dcc7b75d283bfa
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 · 1634 lines · 61.4 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 9d 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 9d ago29use std::path::PathBuf;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago31use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
32use std::time::Duration;
33
34use cosmic::app::{Core, Task};
35use cosmic::iced::futures::channel::mpsc;
36use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago37use cosmic::iced::widget::container::Style as ContainerStyle;
38use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago39use cosmic::iced::widget::text::Wrapping;
40use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago41use cosmic::widget::{self, Column, Row};
42use cosmic::{ApplicationExt, Element};
43use jolt_abi::{borrowed, empty_str, guard, Scratch};
44
45fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
46 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
47}
48
49// --- the arena ---------------------------------------------------------------
50
51struct Edits {
52 tree: Tree,
53 /// Set by every mutation, cleared by a commit that published it.
54 dirty: bool,
55}
56
57static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
58 Mutex::new(Edits {
59 tree: Tree::default(),
60 dirty: false,
61 })
62});
63
64/// What `view` paints: the tree as of the last commit.
65static COMMITTED: LazyLock<Mutex<Arc<Tree>>> = LazyLock::new(Default::default);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago66/// The inbox's `settled` when that commit was made. Written under
67/// `COMMITTED`'s lock, so the two are read as a pair.
68static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago69
70fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
71 let mut e = lock(&EDITS);
72 e.dirty = true;
73 f(&mut e.tree)
74}
75
76fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
77 f(&lock(&EDITS).tree)
78}
79
80// --- events, towards jolt ----------------------------------------------------
81
82struct Event {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago83 seq: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago84 node: i32,
85 name: &'static str,
86 text: String,
87 num: f64,
88}
89
90struct Inbox {
91 queue: VecDeque<Event>,
92 current: Option<Event>,
93 woken: bool,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago94 /// The sequence number of the last event posted.
95 posted: u64,
96 /// The sequence number of the last event the worker dequeued.
97 taken: u64,
98 /// `taken` as of the worker's last `cosmic_wait`. Every event up to here
99 /// had its handler run on an earlier pass, so whatever it re-rendered is
100 /// in the arena by the next commit.
101 settled: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago102}
103
104static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
105 queue: VecDeque::new(),
106 current: None,
107 woken: false,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago108 posted: 0,
109 taken: 0,
110 settled: 0,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago111});
112static BELL: Condvar = Condvar::new();
113
114fn post(node: i32, name: &'static str, text: String, num: f64) {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago115 post_seq(node, name, text, num);
116}
117
118/// Queue an event for the worker; answers its sequence number.
119fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 {
120 let seq = {
121 let mut inbox = lock(&INBOX);
122 inbox.posted += 1;
123 let seq = inbox.posted;
124 inbox.queue.push_back(Event {
125 seq,
126 node,
127 name,
128 text,
129 num,
130 });
131 seq
132 };
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago133 BELL.notify_all();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago134 seq
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago135}
136
137// --- wakes, towards iced -----------------------------------------------------
138
139enum Wake {
140 Tree,
141 Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago142 PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago143}
144
145static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
146static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
147static RAN: AtomicBool = AtomicBool::new(false);
148static CLOSED: AtomicBool = AtomicBool::new(false);
149
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago150/// The window's size in points, as libcosmic last reported it. A client that
151/// lays columns out by arithmetic — frq sizes its message list against the
152/// people panel beside it — has to be able to ask.
153static WINDOW_W: AtomicU32 = AtomicU32::new(0);
154static WINDOW_H: AtomicU32 = AtomicU32::new(0);
155
156/// Where a picture chooser opened by `cosmic_pick_image` has got to.
157enum Pick {
158 Idle,
159 Open,
160 Chosen(PathBuf),
161}
162
163static PICK: Mutex<Pick> = Mutex::new(Pick::Idle);
164
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago165/// The picture a Ctrl+V found on the clipboard, held until the worker asks for
166/// it with `cosmic_clipboard_image_png`.
167static CLIPBOARD_PNG: Mutex<Option<Vec<u8>>> = Mutex::new(None);
168
169/// The clipboard read as PNG. Only image/png is asked for: every desktop that
170/// puts a picture on a clipboard puts one there as PNG too.
171struct ClipboardPng(Vec<u8>);
172
173impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng {
174 fn allowed() -> std::borrow::Cow<'static, [String]> {
175 std::borrow::Cow::Owned(vec!["image/png".to_owned()])
176 }
177}
178
179impl TryFrom<(Vec<u8>, String)> for ClipboardPng {
180 type Error = ();
181
182 fn try_from((bytes, _mime): (Vec<u8>, String)) -> Result<Self, ()> {
183 if bytes.is_empty() {
184 Err(())
185 } else {
186 Ok(Self(bytes))
187 }
188 }
189}
190
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago191/// Named for emoji rather than left to fallback: the first face with a glyph
192/// for a smiley is often a monochrome one, and the pill then shows an outline.
193const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji");
194
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago195fn tell_app(wake: Wake) {
196 if let Some(tx) = lock(&TO_APP).as_ref() {
197 let _ = tx.unbounded_send(wake);
198 }
199}
200
201/// The subscription's stream. It opens with a `Tree` wake so a commit made
202/// between `init` and the subscription starting is not missed, and repeats a
203/// quit asked for before there was anyone to tell.
204fn wakes() -> impl Stream<Item = Message> {
205 let (tx, rx) = mpsc::unbounded();
206 let _ = tx.unbounded_send(Wake::Tree);
207 if QUIT_ASKED.load(SeqCst) {
208 let _ = tx.unbounded_send(Wake::Quit);
209 }
210 *lock(&TO_APP) = Some(tx);
211 rx.map(|wake| match wake {
212 Wake::Tree => Message::Tree,
213 Wake::Quit => Message::Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago214 Wake::PickImage => Message::PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago215 })
216}
217
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago218// --- scroll areas ------------------------------------------------------------
219
220/// Where a scroll area was left, kept by name rather than on the widget.
221///
222/// iced keeps a scrollable's offset in its widget tree, and a widget that is
223/// unmounted and mounted again starts at the top. glimmer clients unmount
224/// lists all the time — frq's lightbox is a screen, so looking at a picture
225/// takes the backlog away — so the place is remembered here, under the
226/// `scroll-key` the client names the list by, and put back when it returns.
227struct ScrollMemo {
228 /// Whether the reader is at the newest line. A `stick-to-bottom` list
229 /// follows what arrives only while this holds.
230 at_end: bool,
231 offset_y: f32,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago232 /// How tall the viewport was when the reader last moved it, which is what
233 /// a jump centres a row in. Zero until they have: a list nobody has
234 /// scrolled has no reported height, and a row put in the middle of a
235 /// viewport of nothing is a row put at the top — which is the right answer
236 /// to give when the height is not known.
237 height: f32,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago238}
239
240/// Two points of slack: a viewport scrolled to its end by a fractional
241/// offset is still at the end.
242const AT_END_SLACK: f32 = 2.0;
243
244fn scroll_name(n: &Node, id: i32) -> String {
245 match n.str("scroll-key") {
246 "" => format!("node-{id}"),
247 key => key.to_owned(),
248 }
249}
250
251fn scroll_id(name: &str) -> widget::Id {
252 widget::Id::new(format!("jolt-scroll-{name}"))
253}
254
255fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
256 if let Some(n) = t.get(id) {
257 f(id, n);
258 for child in &n.children {
259 walk(t, *child, f);
260 }
261 }
262}
263
264/// What a commit asks of one scroll area.
265struct ScrollAsk {
266 name: String,
267 stick: bool,
268 /// The `scroll-to-bottom` counter, and what it was in the tree before.
269 tick: Option<f64>,
270 tick_before: Option<f64>,
271 /// Not in the tree before this commit: mounted, or mounted again.
272 fresh: bool,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago273 /// The row asking to be shown, if one is.
274 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago275}
276
277/// Every scroll area in `now`, and what changed about each since `before`.
278fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
279 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
280 walk(before, before.root_id(), &mut |id, n| {
281 if n.tag == "scroll" {
282 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
283 }
284 });
285
286 let mut asks = Vec::new();
287 walk(now, now.root_id(), &mut |id, n| {
288 if n.tag != "scroll" {
289 return;
290 }
291 let name = scroll_name(n, id);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago292 // The row holding whatever asked to be shown.
293 //
294 // The row, because a row is what `rows::Rows` writes a place down for;
295 // and the row rather than a guess at where it sits, because this used
296 // to answer with its index over the row count. That is a fraction of
297 // the scroll RANGE and not of the content — the two agree only when
298 // the viewport is exactly one row tall — and it took every row for the
299 // same height besides, in a backlog that puts a one-line message next
300 // to a picture. The landing was out by up to a viewport, worst in the
301 // middle of a list.
302 //
303 // While it is asking, not only on the commit the ask arrives. A row
304 // that is not laid out yet has no place written down for it, and the
305 // ask is over in half a second: asking again each commit is what lets
306 // a jump into a conversation the client has only just switched to land
307 // on the frame the rows finally exist.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago308 let mut reveal = None;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago309 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago310 let mut asked = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago311 walk(now, *row, &mut |_, node| {
312 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago313 });
314 if asked {
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago315 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago316 break;
317 }
318 }
319 asks.push(ScrollAsk {
320 fresh: !named_before.contains_key(&name),
321 tick_before: named_before.get(&name).copied().flatten(),
322 tick: n.num("scroll-to-bottom"),
323 stick: n.bool("stick-to-bottom") == Some(true),
324 reveal,
325 name,
326 });
327 });
328 asks
329}
330
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago331/// Where the rows of the scroll area called `name` were last laid out.
332///
333/// By name and not by node, because a scroll area outlives the node ids of a
334/// tree that is rebuilt under it — the same list, and the reader's place in
335/// it, is the thing `scroll-key` names.
336fn placements(name: &str) -> rows::Placements {
337 static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
338 LazyLock::new(|| Mutex::new(HashMap::new()));
339 lock(&BOOKS).entry(name.to_owned()).or_default().clone()
340}
341
342/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
343/// of a viewport `viewport` tall.
344///
345/// The middle rather than the top edge: a line answered three days ago is read
346/// with what was said around it, and a jump that pins it to the ceiling shows
347/// only what came after.
348///
349/// Never above the start of the content — a negative offset is not a place —
350/// and the top edge is the answer while the viewport's height is unknown,
351/// which it is until the reader has scrolled the list once. A row centred in a
352/// viewport of nothing is a row at the top, which is the same answer said
353/// twice, but it is worth being the one that is said on purpose.
354fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
355 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
356}
357
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago358fn snap_to_end(name: &str) -> Task<Message> {
359 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
360}
361
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago362// --- the app -----------------------------------------------------------------
363
364struct App {
365 core: Core,
366 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago367 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago368 /// What the window last wrote back into a control, by node and prop, with
369 /// the sequence number of the event that carried it to the worker.
370 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago371}
372
373#[derive(Clone, Debug)]
374enum Message {
375 Tree,
376 Quit,
377 Click(i32),
378 Toggled(i32, bool),
379 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago380 Paste(i32, String),
381 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago382 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago383 Hover(i32),
384 Unhover(i32),
385 Scrolled(i32, String, Viewport),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago386 PickImage,
387 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago388}
389
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago390/// Lay what was typed over a commit that has not caught up with it.
391///
392/// libcosmic paints a control from the tree, so a commit rendered before the
393/// worker saw the latest keystroke would put the older text back under the
394/// caret, and the next key would land on that. An entry is let go once a
395/// commit was rendered after its event: from then on the component's own
396/// state is the answer, a draft it cleared included.
397fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) {
398 typed.retain(|&(node, key), (seq, value)| {
399 let Some(n) = tree.get(node) else { return false };
400 if *seq <= settled {
401 return false;
402 }
403 if n.props.get(key) != Some(value) {
404 Arc::make_mut(tree).set(node, key, value.clone());
405 }
406 true
407 });
408}
409
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago410impl App {
411 /// A widget does not own its value: the new state goes into the arena and
412 /// 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 9d ago413 /// working control, and its next render is what settles it. Then the event
414 /// goes to the worker, and what was written is held over any commit
415 /// rendered before the worker saw it.
416 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 ago417 edit(|t| t.set(node, key, value.clone()));
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago418 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
419 let seq = post_seq(node, event, text, num);
420 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago421 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago422
423 /// Take the committed tree, and move every scroll area to where it should
424 /// be now that it has changed.
425 ///
426 /// A snap is relative, so a list snapped to its end stays at its end as
427 /// rows arrive under it, until the reader scrolls away.
428 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago429 let (committed, settled) = {
430 let c = lock(&COMMITTED);
431 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
432 };
433 let before = std::mem::replace(&mut self.tree, committed);
434 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago435 let mut tasks = Vec::new();
436 let mut live = HashSet::new();
437 for ask in scroll_asks(&before, &self.tree) {
438 live.insert(ask.name.clone());
439 let memo = self
440 .scrolls
441 .entry(ask.name.clone())
442 .or_insert(ScrollMemo {
443 at_end: ask.stick,
444 offset_y: 0.0,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago445 height: 0.0,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago446 });
447 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 ago448 // A row asking to be shown, and a place written down for it by the
449 // last layout. Both, or there is nothing to do yet: the row is
450 // measured on the frame it appears, and the ask stands until it
451 // has been.
452 if let Some((top, height)) = ask.reveal.and_then(|row| placements(&ask.name).get(row)) {
453 let y = centred_offset(top, height, memo.height);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago454 memo.at_end = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago455 memo.offset_y = y;
456 tasks.push(iced_scrollable::scroll_to(
Revert "Scroll to the row that asked, where it actually is" 98daca1 nandi 8d ago457 scroll_id(&ask.name),
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago458 AbsoluteOffset { x: None, y: Some(y) },
Revert "Scroll to the row that asked, where it actually is" 98daca1 nandi 8d ago459 ));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago460 } else if jumped || (ask.stick && memo.at_end) {
461 memo.at_end = true;
462 tasks.push(snap_to_end(&ask.name));
463 } else if ask.fresh {
464 tasks.push(iced_scrollable::scroll_to(
465 scroll_id(&ask.name),
466 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
467 ));
468 }
469 }
470 // A list that was never scrolled keeps no memo worth the space; one
471 // that was keeps its place for when it comes back.
472 self.scrolls
473 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
474 Task::batch(tasks)
475 }
476
477 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
478 let y = viewport.absolute_offset().y;
479 let room = viewport.content_bounds().height - viewport.bounds().height;
480 let at_end = room - y <= AT_END_SLACK;
481 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
482 at_end,
483 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago484 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago485 });
486 let was = memo.at_end;
487 memo.at_end = at_end;
488 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago489 memo.height = viewport.bounds().height;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago490 // "end" or "away", the strings libvidya emits: frq's handler compares
491 // against "end".
492 if was != at_end {
493 let place = if at_end { "end" } else { "away" };
494 post(node, "change", place.to_owned(), 0.0);
495 }
496 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago497}
498
499impl cosmic::Application for App {
500 type Executor = cosmic::executor::Default;
501 type Flags = String;
502 type Message = Message;
503 const APP_ID: &'static str = "dev.jolt.Glimmer";
504
505 fn core(&self) -> &Core {
506 &self.core
507 }
508
509 fn core_mut(&mut self) -> &mut Core {
510 &mut self.core
511 }
512
513 fn init(core: Core, title: String) -> (Self, Task<Message>) {
514 let mut app = App {
515 core,
516 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago517 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago518 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago519 };
520 // libcosmic's `wayland` feature brings `multi-window` with it, which
521 // makes a window title a per-window thing.
522 app.set_header_title(title.clone());
523 let task = match app.core.main_window_id() {
524 Some(id) => app.set_window_title(title, id),
525 None => Task::none(),
526 };
527 (app, task)
528 }
529
530 fn subscription(&self) -> Subscription<Message> {
531 Subscription::run(wakes)
532 }
533
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago534 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
535 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
536 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
537 }
538
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago539 fn update(&mut self, message: Message) -> Task<Message> {
540 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago541 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago542 Message::Quit => return cosmic::iced::exit(),
543 Message::Click(node) => post(node, "click", String::new(), 0.0),
544 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago545 let num = f64::from(u8::from(on));
546 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago547 }
548 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago549 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
550 }
551 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
552 // clipboard holding a picture has none, so the field comes back as
553 // it was. That is the paste worth reporting: the picture is read
554 // here, where the clipboard is, and `paste-empty` goes to the
555 // worker, which collects it with `cosmic_clipboard_image_png`.
556 Message::Paste(node, text) => {
557 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
558 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
559 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
560 }
561 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
562 }
563 Message::PastedPicture(node, png) => {
564 *lock(&CLIPBOARD_PNG) = png;
565 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago566 }
567 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago568 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
569 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
570 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago571 // The desktop's own chooser, through the portal, on libcosmic's
572 // executor: it is a D-Bus round trip, and the window keeps
573 // painting while it is open.
574 Message::PickImage => {
575 return Task::perform(
576 async {
577 rfd::AsyncFileDialog::new()
578 .set_title("Choose a picture")
579 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
580 .pick_file()
581 .await
582 .map(|file| file.path().to_path_buf())
583 },
584 |path| cosmic::Action::App(Message::Picked(path)),
585 );
586 }
587 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago588 }
589 Task::none()
590 }
591
592 fn view(&self) -> Element<'_, Message> {
593 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago594 let root = element(tree, tree.root_id(), true, false);
595 // A dialog that asked not to be modal, put up here rather than handed
596 // to `dialog` below. It is the same widget in the same place — a
597 // `popover` centres it exactly as `cosmic::app` does — and the whole
598 // of the difference is that this one is not told to intercept the
599 // pointer. That matters to anything the pointer opened: a modal
600 // popover hands the window underneath it a cursor that is
601 // `Unavailable`, so a face that opened a dialog on hover never hears
602 // the pointer leave, and what it opened can never close itself.
603 //
604 // The popover is here whether or not there is anything in it, which
605 // `cosmic::app` says of its own in one line and which this learned
606 // the long way: iced keeps a widget's state by where it sits in the
607 // tree, so a wrapper that comes and goes rebuilds everything under
608 // it — and what "everything" holds is the scroll positions. Wrapping
609 // only when a dialog appeared meant resting the pointer on a face
610 // jumped the conversation behind it.
611 let mut popover = widget::popover(root);
612 if let Some(id) = find_dialog(tree, false) {
613 // The dialog reports its own pointer, on the same two events a
614 // face or a pill reports theirs. Without it a dialog the pointer
615 // opened can only be read at arm's length: the client is told the
616 // pointer left what opened it and never told it arrived here, so
617 // the one way to keep it up is not to move — and everything in it
618 // is out of reach.
619 let popup = widget::mouse_area(dialog_of(tree, id))
620 .on_enter(Message::Hover(id))
621 .on_exit(Message::Unhover(id));
622 popover = popover.popup(popup);
623 }
624 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago625 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago626
627 /// The MODAL dialog the tree is carrying, if it is carrying one.
628 ///
629 /// A client says there is one by putting a `dialog` node in the tree and
630 /// says there is not by leaving it out — the same way it says anything
631 /// else. What comes back is libcosmic's own dialog: centred, over a
632 /// dimmed window, and closed by the buttons the client hung on it.
633 ///
634 /// A dialog that says `modal false` does not come back here. This hook is
635 /// the modal one whether the client wants it or not — `cosmic::app` wraps
636 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
637 /// that kind up itself. See `dialog_of`.
638 fn dialog(&self) -> Option<Element<'_, Message>> {
639 let tree = &*self.tree;
640 let id = find_dialog(tree, true)?;
641 Some(dialog_of(tree, id))
642 }
643}
644
645/// The first `dialog` node in the tree whose modality is `modal`.
646///
647/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
648/// not, which is the shape everything else here takes — a prop left out is
649/// the ordinary answer.
650fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
651 let mut found = None;
652 walk(t, t.root_id(), &mut |id, n| {
653 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
654 found = Some(id);
655 }
656 });
657 found
658}
659
660/// One `dialog` node as libcosmic's dialog.
661///
662/// `label` is its heading and `body` the line under it. Children are its
663/// controls, in order, except that a child carrying `slot` "primary" or
664/// "secondary" becomes that action instead — which is where libcosmic puts
665/// the buttons, at the foot and to the right.
666fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
667 let Some(n) = t.get(id) else {
668 return widget::Space::new().width(0).height(0).into();
669 };
670 let mut d = widget::dialog();
671 if !n.label().is_empty() {
672 d = d.title(n.label().to_owned());
673 }
674 if !n.str("body").is_empty() {
675 d = d.body(n.str("body").to_owned());
676 }
677 if let Some(w) = n.num("max-width") {
678 d = d.max_width(w as f32);
679 }
680 for child in &n.children {
681 let Some(c) = t.get(*child) else { continue };
682 let el = element(t, *child, true, false);
683 d = match c.str("slot") {
684 "primary" => d.primary_action(el),
685 "secondary" => d.secondary_action(el),
686 _ => d.control(el),
687 };
688 }
689 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago690}
691
692// --- props into layout -----------------------------------------------------------
693
694/// `margin` all round, with `margin-top` and its siblings overriding a side.
695fn margins(n: &Node) -> Padding {
696 let all = n.num("margin").unwrap_or(0.0) as f32;
697 let side = |key| n.num(key).map_or(all, |v| v as f32);
698 Padding {
699 top: side("margin-top"),
700 right: side("margin-right"),
701 bottom: side("margin-bottom"),
702 left: side("margin-left"),
703 }
704}
705
706/// A width the client asked for. Zero is the client saying "none": frq writes
707/// `:width-request 0` on its message column whenever the people panel is shut,
708/// and taken literally that is a backlog laid out zero points wide.
709fn width_request(n: &Node) -> Option<f32> {
710 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
711}
712
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago713/// `align`, or `default` where it is not set. A row centres its children on
714/// the cross axis by default — a label beside a button otherwise sits against
715/// the top of the button — and a column starts them at the left.
716fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago717 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago718 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago719 "center" => Alignment::Center,
720 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago721 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago722 }
723}
724
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago725fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
726 cosmic::theme::Container::custom(move |_| ContainerStyle {
727 background: Some(Background::Color(color)),
728 border: Border {
729 radius: radius.into(),
730 ..Border::default()
731 },
732 text_color: Some(Color::WHITE),
733 ..ContainerStyle::default()
734 })
735}
736
737/// A colour for somebody, from their name, so the same person is the same
738/// colour everywhere they appear.
739fn name_colour(name: &str) -> Color {
740 const PALETTE: [(f32, f32, f32); 8] = [
741 (0.83, 0.33, 0.33),
742 (0.85, 0.55, 0.20),
743 (0.62, 0.62, 0.18),
744 (0.30, 0.65, 0.35),
745 (0.20, 0.62, 0.62),
746 (0.30, 0.50, 0.85),
747 (0.55, 0.40, 0.85),
748 (0.80, 0.35, 0.65),
749 ];
750 let hash = name
751 .bytes()
752 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
753 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
754 Color::from_rgb(r, g, b)
755}
756
757/// A picture that answers a click, with the pointer saying so.
758fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
759 if !enabled {
760 return el;
761 }
762 widget::mouse_area(el)
763 .on_press(Message::Click(id))
764 .interaction(cosmic::iced::mouse::Interaction::Pointer)
765 .into()
766}
767
768fn picture(path: &str) -> Option<widget::image::Handle> {
769 (!path.is_empty() && std::path::Path::new(path).exists())
770 .then(|| widget::image::Handle::from_path(path))
771}
772
773// --- the tree into widgets ---------------------------------------------------------
774
775/// One node and everything under it, as widgets.
776///
777/// `enabled` is inherited: an insensitive container takes its whole subtree out
778/// of interaction. `in_row` is whether the parent lays its children out across:
779/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
780/// column in a column takes the width and a column in a row does not take the
781/// row's slack unless it says `fill-height`.
782fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago783 let Some(n) = t.get(id) else {
784 return Column::new().into();
785 };
786 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago787 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago788 // glimmer-jvui's theme spacing, where the client does not say: a list of
789 // cards with nothing between them reads as one slab.
790 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago791 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 ago792
793 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago794 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago795 .width(Length::Fill)
796 .height(Length::Fill)
797 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago798 // Sizes are set only where something asked for one. iced's rows and
799 // columns take `Fill` on an axis from any child that fills it, which is
800 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
801 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
802 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago803 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago804 let across = n.str("orientation") == "horizontal";
805 if across {
806 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago807 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago808 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago809 .align_y(alignment(n, Alignment::Center));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago810 // A row fills the width it is in only when it or something in
811 // it asks to; otherwise a line of buttons would spread out.
812 match width_request(n) {
813 Some(w) => row = row.width(w),
814 None if fill_height => row = row.width(Length::Fill),
815 None => {}
816 }
817 if fill_height {
818 row = row.height(Length::Fill);
819 }
820 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago821 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago822 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago823 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago824 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago825 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago826 match width_request(n) {
827 Some(w) => column = column.width(w),
828 None if fill_height || !in_row => column = column.width(Length::Fill),
829 None => {}
830 }
831 if fill_height {
832 column = column.height(Length::Fill);
833 }
834 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago835 }
836 }
837 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago838 let column = Column::with_children(children(false))
839 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago840 .padding(24)
841 .width(Length::Fill);
842 let mut inner = widget::container(column).width(Length::Fill);
843 if let Some(max) = n.num("max-width") {
844 inner = inner.max_width(max as f32);
845 }
846 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago847 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago848 .height(Length::Fill)
849 .into()
850 }
851 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago852 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 ago853 if n.tag == "frame" && !n.label().is_empty() {
854 column = column.push(widget::text::heading(n.label()));
855 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago856 let card = widget::container(column.extend(children(false)))
857 .padding(12)
858 .class(cosmic::theme::Container::Card);
859 match width_request(n) {
860 Some(w) => card.width(w).into(),
861 None if !in_row => card.width(Length::Fill).into(),
862 None => card.into(),
863 }
864 }
865 // Always fills both ways: a viewport that only fills its width asks its
866 // column for no height, and is given none. The content is held to its
867 // own height, since iced will not scroll content that fills the axis it
868 // scrolls along.
869 "scroll" => {
870 let name = scroll_name(n, id);
871 let content = Column::with_children(children(false))
872 .spacing(spacing)
873 .width(Length::Fill)
874 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago875 // Wrapped in the thing that writes down where each row landed, so
876 // that "take me to this line" has an answer in points — which is
877 // the only thing a scroll area can be told. See `rows`.
878 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago879 widget::scrollable(content)
880 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago881 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago882 .height(Length::Fill)
883 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago884 .into()
885 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago886 // Word wrapping that falls back to breaking inside a word: a URL is one
887 // word, and it otherwise runs straight past the edge of its column.
888 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
889 .wrapping(Wrapping::WordOrGlyph)
890 .into(),
891 "label" => widget::text::body(n.label())
892 .wrapping(Wrapping::WordOrGlyph)
893 .into(),
894 "title" => widget::text::title3(n.label())
895 .wrapping(Wrapping::WordOrGlyph)
896 .into(),
897 "title-2" => widget::text::title4(n.label())
898 .wrapping(Wrapping::WordOrGlyph)
899 .into(),
900 "dim-label" => widget::text::caption(n.label())
901 .wrapping(Wrapping::WordOrGlyph)
902 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago903 "button" => {
904 let button = match n.str("kind") {
905 "primary" => widget::button::suggested(n.label()),
906 "destructive" => widget::button::destructive(n.label()),
907 _ => widget::button::standard(n.label()),
908 };
909 button
910 .on_press_maybe(enabled.then_some(Message::Click(id)))
911 .into()
912 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago913 "link" => widget::button::link(n.label().to_owned())
914 .on_press_maybe(enabled.then_some(Message::Click(id)))
915 .into(),
916 // A dot that says whether the thing is live, and the words beside it.
917 "status" => {
918 let colour = if n.bool("live") == Some(true) {
919 Color::from_rgb(0.30, 0.72, 0.40)
920 } else {
921 Color::from_rgb(0.55, 0.55, 0.55)
922 };
923 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
924 Row::new()
925 .spacing(6)
926 .align_y(Alignment::Center)
927 .push(dot)
928 .push(widget::text::caption(n.label()))
929 .into()
930 }
931 "spinner" => {
932 let mut row = Row::new()
933 .spacing(8)
934 .align_y(Alignment::Center)
935 .push(widget::progress_bar::indeterminate_circular().size(16.0));
936 if !n.label().is_empty() {
937 row = row.push(widget::text::caption(n.label()));
938 }
939 row.into()
940 }
941 "emoji" => {
942 let glyph = match n.str("emoji") {
943 "" => n.label(),
944 e => e,
945 };
946 widget::text(glyph.to_owned())
947 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago948 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago949 .into()
950 }
951 // A round picture, or the initial on a colour from the name: most
952 // 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 ago953 //
954 // And the three things a face is for besides being looked at. It
955 // painted as a picture and nothing else until now: a client that
956 // asked a face to answer a click, to report the pointer arriving, or
957 // to carry a card under it was handed a portrait that did none of
958 // them — so the profile behind every avatar in the window was
959 // unreachable, and the hover card written for it never appeared.
960 // Those are the same three things `reaction` below does, so they are
961 // done the same way: `mouse_area` for the press and the two edges of
962 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago963 "avatar" => {
964 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 ago965 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago966 Some(handle) => widget::image(handle)
967 .width(size)
968 .height(size)
969 .content_fit(ContentFit::Cover)
970 .border_radius(size / 2.0)
971 .into(),
972 None => {
973 let initial: String = n
974 .label()
975 .trim_start_matches(|c: char| !c.is_alphanumeric())
976 .chars()
977 .next()
978 .map(|c| c.to_uppercase().collect())
979 .unwrap_or_default();
980 widget::container(widget::text(initial).size(size * 0.45))
981 .center(Length::Fixed(size))
982 .class(filled(name_colour(n.label()), size / 2.0))
983 .into()
984 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago985 };
986 // The hover is reported whether or not the face is enabled: it
987 // says where the pointer is, which is true of an insensitive
988 // picture too. The press is not — an insensitive subtree is out
989 // of interaction, which is what `enabled` means here.
990 let mut area = widget::mouse_area(face)
991 .on_enter(Message::Hover(id))
992 .on_exit(Message::Unhover(id));
993 if enabled {
994 area = area
995 .on_press(Message::Click(id))
996 .interaction(cosmic::iced::mouse::Interaction::Pointer);
997 }
998 if n.children.is_empty() {
999 area.into()
1000 } else {
1001 widget::tooltip(
1002 area,
1003 Column::with_children(children(false)).spacing(4),
1004 widget::tooltip::Position::Bottom,
1005 )
1006 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1007 }
1008 }
1009 // A pill: an emoji, how many people, and whether you are one of them.
1010 // What the client hangs under it is its hover card, shown while the
1011 // pointer is on the pill.
1012 "reaction" => {
1013 let glyph = match n.str("emoji") {
1014 "" => n.label(),
1015 e => e,
1016 };
1017 let size = n.num("size").unwrap_or(16.0) as f32;
1018 let mut content = Row::new()
1019 .spacing(4)
1020 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago1021 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1022 let count = n.num("count").unwrap_or(0.0);
1023 if count > 0.0 {
1024 content = content.push(widget::text::caption(format!("{count}")));
1025 }
1026 let class = if n.bool("mine") == Some(true) {
1027 widget::button::ButtonClass::Suggested
1028 } else {
1029 widget::button::ButtonClass::Standard
1030 };
1031 let pill = widget::button::custom(content)
1032 .padding([2, 8])
1033 .class(class)
1034 .on_press_maybe(enabled.then_some(Message::Click(id)));
1035 let pill = widget::mouse_area(pill)
1036 .on_enter(Message::Hover(id))
1037 .on_exit(Message::Unhover(id));
1038 if n.children.is_empty() {
1039 pill.into()
1040 } else {
1041 widget::tooltip(
1042 pill,
1043 Column::with_children(children(false)).spacing(4),
1044 widget::tooltip::Position::Bottom,
1045 )
1046 .into()
1047 }
1048 }
1049 // One tag for both kinds of picture, as in libvidya. `feed` is live
1050 // pixels pushed under a name, which nothing pushes here yet, so it
1051 // holds the slot the layout gave it.
1052 "image" => {
1053 let max_w = n.num("max-width").map(|v| v as f32);
1054 let max_h = n.num("max-height").map(|v| v as f32);
1055 if !n.str("feed").is_empty() {
1056 let w = max_w.unwrap_or(160.0);
1057 let h = max_h.unwrap_or(w * 0.75);
1058 widget::container(widget::text::caption("video"))
1059 .center_x(Length::Fixed(w))
1060 .center_y(Length::Fixed(h))
1061 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1062 .into()
1063 } else if let Some(handle) = picture(n.str("src")) {
1064 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1065 if n.bool("fit") == Some(true) {
1066 image = image.width(Length::Fill).height(Length::Fill);
1067 } else if let Some(size) = n.num("size") {
1068 image = image.width(size as f32).height(size as f32);
1069 }
1070 let mut bounded = widget::container(image);
1071 if let Some(w) = max_w {
1072 bounded = bounded.max_width(w);
1073 }
1074 if let Some(h) = max_h {
1075 bounded = bounded.max_height(h);
1076 }
1077 clickable(bounded.into(), id, enabled)
1078 } else {
1079 widget::Space::new().width(0).height(0).into()
1080 }
1081 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1082 "checkbutton" => {
1083 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1084 if enabled {
1085 check = check.on_toggle(move |on| Message::Toggled(id, on));
1086 }
1087 check.into()
1088 }
1089 "entry" => {
1090 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1091 if enabled {
1092 entry = entry
1093 .on_input(move |text| Message::Change(id, text))
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1094 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1095 .on_submit(move |_| Message::Activate(id));
1096 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1097 let width = match width_request(n) {
1098 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1099 _ => Length::Fill,
1100 };
1101 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1102 }
1103 "separator" => widget::divider::horizontal::default().into(),
1104 "spacer" => {
1105 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1106 if n.str("expand").is_empty() {
1107 widget::Space::new().width(size).height(size).into()
1108 } else {
1109 widget::Space::new().width(Length::Fill).height(size).into()
1110 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1111 }
1112 "progress" => {
1113 let bar =
1114 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1115 if n.label().is_empty() {
1116 bar.into()
1117 } else {
1118 Column::new()
1119 .spacing(4)
1120 .push(widget::text::caption(n.label()))
1121 .push(bar)
1122 .into()
1123 }
1124 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 8d ago1125 // The one node that is not painted where it stands. libcosmic puts a
1126 // dialog up itself, centred over the window and dimming what is
1127 // behind it — `Application::dialog` is the hook, and it is asked for
1128 // one separately from `view`. So the tree carries the dialog wherever
1129 // the client found it convenient to write it, `App::dialog` goes and
1130 // finds it there, and this leaves nothing behind in the layout. A
1131 // node rendered in both places would be painted twice.
1132 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1133 // Kept rather than refused, as in libvidya: a tag this backend has not
1134 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1135 _ => Column::with_children(children(false))
1136 .spacing(spacing)
1137 .padding(margins(n))
1138 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1139 };
1140
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1141 // The containers and the entry size themselves above; anything else asked
1142 // for a width gets it from a wrapper.
1143 match (n.tag.as_str(), width_request(n)) {
1144 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1145 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1146 }
1147}
1148
1149// --- the C ABI: the loop -------------------------------------------------------
1150
1151static TITLE: Mutex<String> = Mutex::new(String::new());
1152
1153/// The window's title, read when `cosmic_run` opens it. A call of its own
1154/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1155/// `cosmic_run` has to be one.
1156///
1157/// # Safety
1158/// `title` is null or a NUL-terminated string.
1159#[no_mangle]
1160pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1161 let title = borrowed(title);
1162 guard((), || *lock(&TITLE) = title)
1163}
1164
1165/// Open the window and run libcosmic until it closes. Blocks; call it on the
1166/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1167///
1168/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1169/// in this process — winit's event loop cannot be made twice.
1170#[no_mangle]
1171pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1172 let status = guard(1, || {
1173 let title = lock(&TITLE).clone();
1174 if RAN.swap(true, SeqCst) {
1175 log::error!("jolt-cosmic: a window already ran in this process");
1176 return 2;
1177 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago1178 // The size asked for, until libcosmic reports the one it got.
1179 WINDOW_W.store(width.max(1) as u32, SeqCst);
1180 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1181 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1182 let mut settings = cosmic::app::Settings::default().size(size);
1183 match mode {
1184 1 => settings = settings.theme(cosmic::Theme::dark()),
1185 2 => settings = settings.theme(cosmic::Theme::light()),
1186 _ => {}
1187 }
1188 match cosmic::app::run::<App>(settings, title) {
1189 Ok(()) => 0,
1190 Err(err) => {
1191 eprintln!("jolt-cosmic: {err}");
1192 1
1193 }
1194 }
1195 });
1196 // Outside the guard, so a panic in libcosmic still releases the worker.
1197 *lock(&TO_APP) = None;
1198 CLOSED.store(true, SeqCst);
1199 BELL.notify_all();
1200 status
1201}
1202
1203/// 1 once `cosmic_run` has returned.
1204#[no_mangle]
1205pub extern "C" fn cosmic_should_close() -> c_int {
1206 c_int::from(CLOSED.load(SeqCst))
1207}
1208
1209/// Close the window. Asked before the window exists, it closes on opening.
1210#[no_mangle]
1211pub extern "C" fn cosmic_quit() {
1212 guard((), || {
1213 QUIT_ASKED.store(true, SeqCst);
1214 tell_app(Wake::Quit);
1215 })
1216}
1217
1218/// Publish the edits since the last commit. Answers 1 when there were any.
1219#[no_mangle]
1220pub extern "C" fn cosmic_tree_commit() -> c_int {
1221 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1222 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1223 let snapshot = {
1224 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1225 // A pass that only settled events still publishes, so a control
1226 // holding typed text over an older commit lets go of it.
1227 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1228 return 0;
1229 }
1230 e.dirty = false;
1231 Arc::new(e.tree.clone())
1232 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1233 {
1234 let mut committed = lock(&COMMITTED);
1235 *committed = snapshot;
1236 COMMITTED_SETTLED.store(settled, SeqCst);
1237 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1238 tell_app(Wake::Tree);
1239 1
1240 })
1241}
1242
1243/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1244/// closing. Answers 1 when an event is waiting.
1245#[no_mangle]
1246pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1247 guard(0, || {
1248 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1249 let (mut inbox, _) = BELL
1250 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1251 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1252 })
1253 .unwrap_or_else(|poisoned| poisoned.into_inner());
1254 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1255 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1256 c_int::from(!inbox.queue.is_empty())
1257 })
1258}
1259
1260/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1261#[no_mangle]
1262pub extern "C" fn cosmic_wake() {
1263 guard((), || {
1264 lock(&INBOX).woken = true;
1265 BELL.notify_all();
1266 })
1267}
1268
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 9d ago1269// --- the C ABI: the window and the desktop -----------------------------------------
1270
1271/// The window's width in points; the size asked for until it has opened.
1272#[no_mangle]
1273pub extern "C" fn cosmic_window_width() -> c_int {
1274 WINDOW_W.load(SeqCst) as c_int
1275}
1276
1277#[no_mangle]
1278pub extern "C" fn cosmic_window_height() -> c_int {
1279 WINDOW_H.load(SeqCst) as c_int
1280}
1281
1282/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1283/// there is no window to ask from; the choice arrives through
1284/// `cosmic_picked_image`.
1285#[no_mangle]
1286pub extern "C" fn cosmic_pick_image() -> c_int {
1287 guard(0, || {
1288 if lock(&TO_APP).is_none() {
1289 return 0;
1290 }
1291 *lock(&PICK) = Pick::Open;
1292 tell_app(Wake::PickImage);
1293 1
1294 })
1295}
1296
1297/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1298/// was chosen since the last call; 0 while the chooser is open, after it was
1299/// cancelled, or when the picture could not be read.
1300///
1301/// # Safety
1302/// `path` is null or a NUL-terminated string.
1303#[no_mangle]
1304pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1305 let path = borrowed(path);
1306 guard(0, || {
1307 let chosen = {
1308 let mut pick = lock(&PICK);
1309 match std::mem::replace(&mut *pick, Pick::Idle) {
1310 Pick::Chosen(chosen) => chosen,
1311 other => {
1312 *pick = other;
1313 return 0;
1314 }
1315 }
1316 };
1317 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1318 Ok(()) => 1,
1319 Err(err) => {
1320 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1321 0
1322 }
1323 }
1324 })
1325}
1326
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1327/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1328/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1329/// already taken, or when the file could not be written.
1330///
1331/// # Safety
1332/// `path` is null or a NUL-terminated string.
1333#[no_mangle]
1334pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1335 let path = borrowed(path);
1336 guard(0, || {
1337 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1338 return 0;
1339 };
1340 match std::fs::write(&*path, png) {
1341 Ok(()) => 1,
1342 Err(err) => {
1343 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1344 0
1345 }
1346 }
1347 })
1348}
1349
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1350// --- the C ABI: events -----------------------------------------------------------
1351
1352static EVENT_NAME: Scratch = Scratch::new();
1353static EVENT_TEXT: Scratch = Scratch::new();
1354
1355/// Dequeue one event; 1 while there was one. The accessors describe it.
1356#[no_mangle]
1357pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1358 guard(0, || {
1359 let mut inbox = lock(&INBOX);
1360 let next = inbox.queue.pop_front();
1361 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1362 if let Some(e) = &next {
1363 inbox.taken = e.seq;
1364 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 9d ago1365 inbox.current = next;
1366 c_int::from(got)
1367 })
1368}
1369
1370#[no_mangle]
1371pub extern "C" fn cosmic_tree_event_node() -> c_int {
1372 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1373}
1374
1375#[no_mangle]
1376pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1377 guard(empty_str(), || {
1378 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1379 })
1380}
1381
1382#[no_mangle]
1383pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1384 guard(empty_str(), || {
1385 let text = lock(&INBOX)
1386 .current
1387 .as_ref()
1388 .map(|e| e.text.clone())
1389 .unwrap_or_default();
1390 EVENT_TEXT.lend(text)
1391 })
1392}
1393
1394#[no_mangle]
1395pub extern "C" fn cosmic_tree_event_num() -> f64 {
1396 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1397}
1398
1399// --- the C ABI: nodes --------------------------------------------------------------
1400
1401static PROPS: Scratch = Scratch::new();
1402static DUMP: Scratch = Scratch::new();
1403
1404#[no_mangle]
1405pub extern "C" fn cosmic_tree_root() -> c_int {
1406 guard(0, || edit(Tree::root))
1407}
1408
1409/// # Safety
1410/// `tag` is null or a NUL-terminated string.
1411#[no_mangle]
1412pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1413 let tag = borrowed(tag);
1414 guard(0, || edit(|t| t.new_node(&tag)))
1415}
1416
1417#[no_mangle]
1418pub extern "C" fn cosmic_node_free(node: c_int) {
1419 guard((), || edit(|t| t.free(node)))
1420}
1421
1422#[no_mangle]
1423pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1424 guard(0, || c_int::from(read(|t| t.exists(node))))
1425}
1426
1427/// # Safety
1428/// `key` and `value` are null or NUL-terminated strings.
1429#[no_mangle]
1430pub unsafe extern "C" fn cosmic_node_set_str(
1431 node: c_int,
1432 key: *const c_char,
1433 value: *const c_char,
1434) {
1435 let (key, value) = (borrowed(key), borrowed(value));
1436 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1437}
1438
1439/// # Safety
1440/// `key` is null or a NUL-terminated string.
1441#[no_mangle]
1442pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1443 let key = borrowed(key);
1444 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1445}
1446
1447/// # Safety
1448/// `key` is null or a NUL-terminated string.
1449#[no_mangle]
1450pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1451 let key = borrowed(key);
1452 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1453}
1454
1455#[no_mangle]
1456pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1457 guard((), || edit(|t| t.clear_props(node)))
1458}
1459
1460#[no_mangle]
1461pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1462 guard(empty_str(), || {
1463 PROPS.lend(read(|t| {
1464 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1465 }))
1466 })
1467}
1468
1469#[no_mangle]
1470pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1471 guard(0, || {
1472 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1473 })
1474}
1475
1476#[no_mangle]
1477pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1478 guard(0, || {
1479 read(|t| {
1480 t.get(node)
1481 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1482 .unwrap_or(0)
1483 })
1484 })
1485}
1486
1487#[no_mangle]
1488pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1489 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1490}
1491
1492/// Unparents AND frees `child` with everything under it.
1493#[no_mangle]
1494pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1495 guard((), || edit(|t| t.remove(parent, child)))
1496}
1497
1498#[no_mangle]
1499pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1500 guard(0, || {
1501 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1502 })
1503}
1504
1505#[no_mangle]
1506pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1507 guard(0, || {
1508 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1509 })
1510}
1511
1512/// The subtree at `node` as hiccup; 0 is the root.
1513#[no_mangle]
1514pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1515 guard(empty_str(), || {
1516 DUMP.lend(read(|t| {
1517 let id = if node == 0 { t.root_id() } else { node };
1518 t.dump(id)
1519 }))
1520 })
1521}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1522
1523#[cfg(test)]
1524mod tests {
1525 use super::*;
1526
1527 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1528 let id = t.new_node(tag);
1529 assert!(t.append(parent, id));
1530 id
1531 }
1532
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 9d ago1533 #[test]
1534 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1535 let mut t = Tree::default();
1536 let root = t.root();
1537 let entry = node(&mut t, root, "entry");
1538 t.set(entry, "text", Prop::Str("a".into()));
1539 let mut tree = Arc::new(t);
1540 let mut typed = HashMap::new();
1541 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1542
1543 // Rendered before the worker saw the "b".
1544 keep_typed(&mut tree, &mut typed, 1);
1545 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1546 assert_eq!(typed.len(), 1);
1547
1548 // Rendered after: the component cleared its draft, and that stands.
1549 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1550 keep_typed(&mut tree, &mut typed, 2);
1551 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1552 assert!(typed.is_empty());
1553 }
1554
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1555 #[test]
1556 fn a_zero_width_request_is_no_request() {
1557 let mut t = Tree::default();
1558 let root = t.root();
1559 let column = node(&mut t, root, "vbox");
1560 t.set(column, "width-request", Prop::Num(0.0));
1561 assert_eq!(width_request(t.get(column).unwrap()), None);
1562 t.set(column, "width-request", Prop::Num(260.0));
1563 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1564 }
1565
1566 #[test]
1567 fn a_scroll_is_named_by_its_scroll_key() {
1568 let mut t = Tree::default();
1569 let root = t.root();
1570 let list = node(&mut t, root, "scroll");
1571 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1572 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1573 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1574 }
1575
1576 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1577 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1578 // A row halfway down a long backlog, in a 600pt viewport: half the
1579 // viewport above it, less half the row.
1580 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1581 // The same row with no viewport reported yet: its own top.
1582 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1583 // A row near the top cannot be centred without scrolling above the
1584 // content, and nothing is above the content.
1585 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1586 // A row taller than the viewport is shown from its own top: there is
1587 // no middle of it to put in the middle.
1588 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1589 }
1590
1591 #[test]
1592 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1593 let mut t = Tree::default();
1594 let root = t.root();
1595 let list = node(&mut t, root, "scroll");
1596 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1597 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1598 let before = t.clone();
1599 t.set(rows[3], "scroll-here", Prop::Bool(true));
1600
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1601 // 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 9d ago1602 let asks = scroll_asks(&before, &t);
1603 assert_eq!(asks.len(), 1);
1604 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1605 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1606
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1607 // And it goes on asking while the row is still asking: the row may not
1608 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1609 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 8d ago1610 assert_eq!(again[0].reveal, Some(rows[3]));
1611
1612 // A node asking from deeper inside a row answers with the row.
1613 t.set(rows[3], "scroll-here", Prop::Bool(false));
1614 let inner = node(&mut t, rows[1], "vbox");
1615 t.set(inner, "scroll-here", Prop::Bool(true));
1616 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1617
1618 // Nothing asking, nothing to reveal.
1619 t.set(inner, "scroll-here", Prop::Bool(false));
1620 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 9d ago1621 }
1622
1623 #[test]
1624 fn a_jump_is_the_counter_moving() {
1625 let mut t = Tree::default();
1626 let root = t.root();
1627 let list = node(&mut t, root, "scroll");
1628 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1629 let before = t.clone();
1630 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1631 let asks = scroll_asks(&before, &t);
1632 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1633 }
1634}