nandi/jolt-nativepublic Fork 0
121e5f1d751e8003ea229e70debf486b9bea3286
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 · 2052 lines · 79.6 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
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago22mod rows;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago23mod tree;
24
25pub use tree::{Node, Prop, Tree};
26
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago27use std::collections::{HashMap, HashSet, VecDeque};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago28use std::ffi::{c_char, c_int};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago29use std::path::PathBuf;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago31use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
32use std::time::Duration;
33
34use cosmic::app::{Core, Task};
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago35use cosmic::iced::alignment::Horizontal;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago36use cosmic::iced::futures::channel::mpsc;
37use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago38use cosmic::iced::widget::container::Style as ContainerStyle;
39use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago40use cosmic::iced::widget::text::Wrapping;
41use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 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,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago232 /// What the client was last told about that, and `None` while it has been
233 /// told nothing.
234 ///
235 /// Held apart from `at_end` because the two answer different questions.
236 /// `at_end` is where this list is; `told` is what the client believes,
237 /// and a report is worth making exactly when they differ. Reporting on a
238 /// change in `at_end` alone loses two cases, and both of them end with a
239 /// "jump to present" button over a backlog that is already at its newest
240 /// line. A list mounting says nothing, because the place it opens at is
241 /// the place the memo guessed it would — but the client's belief is about
242 /// the list this one REPLACED, which in frq is another room entirely. And
243 /// a jump says nothing, because the branch that asked for it marked the
244 /// memo on the way past, so the report that came back agreed with it.
245 told: Option<bool>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago246 offset_y: f32,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago247 /// How tall the viewport was when the reader last moved it, which is what
248 /// a jump centres a row in. Zero until they have: a list nobody has
249 /// scrolled has no reported height, and a row put in the middle of a
250 /// viewport of nothing is a row put at the top — which is the right answer
251 /// to give when the height is not known.
252 height: f32,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago253}
254
255/// Two points of slack: a viewport scrolled to its end by a fractional
256/// offset is still at the end.
257const AT_END_SLACK: f32 = 2.0;
258
259fn scroll_name(n: &Node, id: i32) -> String {
260 match n.str("scroll-key") {
261 "" => format!("node-{id}"),
262 key => key.to_owned(),
263 }
264}
265
266fn scroll_id(name: &str) -> widget::Id {
267 widget::Id::new(format!("jolt-scroll-{name}"))
268}
269
270fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
271 if let Some(n) = t.get(id) {
272 f(id, n);
273 for child in &n.children {
274 walk(t, *child, f);
275 }
276 }
277}
278
279/// What a commit asks of one scroll area.
280struct ScrollAsk {
281 name: String,
282 stick: bool,
283 /// The `scroll-to-bottom` counter, and what it was in the tree before.
284 tick: Option<f64>,
285 tick_before: Option<f64>,
286 /// Not in the tree before this commit: mounted, or mounted again.
287 fresh: bool,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago288 /// The row asking to be shown, if one is.
289 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago290}
291
292/// Every scroll area in `now`, and what changed about each since `before`.
293fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
294 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
295 walk(before, before.root_id(), &mut |id, n| {
296 if n.tag == "scroll" {
297 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
298 }
299 });
300
301 let mut asks = Vec::new();
302 walk(now, now.root_id(), &mut |id, n| {
303 if n.tag != "scroll" {
304 return;
305 }
306 let name = scroll_name(n, id);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago307 // The row holding whatever asked to be shown.
308 //
309 // The row, because a row is what `rows::Rows` writes a place down for;
310 // and the row rather than a guess at where it sits, because this used
311 // to answer with its index over the row count. That is a fraction of
312 // the scroll RANGE and not of the content — the two agree only when
313 // the viewport is exactly one row tall — and it took every row for the
314 // same height besides, in a backlog that puts a one-line message next
315 // to a picture. The landing was out by up to a viewport, worst in the
316 // middle of a list.
317 //
318 // While it is asking, not only on the commit the ask arrives. A row
319 // that is not laid out yet has no place written down for it, and the
320 // ask is over in half a second: asking again each commit is what lets
321 // a jump into a conversation the client has only just switched to land
322 // on the frame the rows finally exist.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago323 let mut reveal = None;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago324 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago325 let mut asked = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago326 walk(now, *row, &mut |_, node| {
327 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago328 });
329 if asked {
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago330 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago331 break;
332 }
333 }
334 asks.push(ScrollAsk {
335 fresh: !named_before.contains_key(&name),
336 tick_before: named_before.get(&name).copied().flatten(),
337 tick: n.num("scroll-to-bottom"),
338 stick: n.bool("stick-to-bottom") == Some(true),
339 reveal,
340 name,
341 });
342 });
343 asks
344}
345
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago346/// What a commit asks of one scroll area, decided.
347#[derive(Debug, PartialEq)]
348enum ScrollMove {
349 /// Show this row of it.
350 Reveal(i32),
351 /// Take it to its newest line.
352 End,
353 /// Put it back where the reader left it, in points.
354 Restore(f32),
355 /// Leave it alone.
356 Stay,
357}
358
359/// What to do with one scroll area, and the memo brought up to date.
360///
361/// Split out from `take_tree` because it is the whole of the thinking and none
362/// of the toolkit: everything here is the ask beside what is remembered, so it
363/// can be read — and tested — without a window to put it in.
364fn scroll_move(ask: &ScrollAsk, memo: &mut ScrollMemo) -> ScrollMove {
365 // A list this tree did not have a moment ago is a list the client has
366 // heard nothing about, whatever it heard about the last one under this
367 // name. Forgetting what it was told is what makes the next report happen,
368 // so that what it believes is about the list it is looking at — in frq,
369 // the room it is in rather than the room it came from.
370 if ask.fresh {
371 memo.told = None;
372 }
373 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
374 // A row is asking to be shown. Whether or not it can be shown yet,
375 // nothing else may move this list while it is asking: the branch below
376 // would otherwise take a reader who was at the newest line — which is
377 // most readers, most of the time — straight back to it, and a jump that
378 // ends at the bottom of the room reads as a jump that did nothing.
379 //
380 // Nothing below marks the memo, either. Where a list lands is `report`'s
381 // to hear from the toolkit and pass on; a memo that wrote the answer down
382 // here would agree with the report when it came and keep it from the
383 // client — the list moved, nobody was told, and the client went on
384 // believing whatever it believed before. Which is a "jump to present"
385 // button over a backlog that is already at its newest line.
386 if let Some(row) = ask.reveal {
387 ScrollMove::Reveal(row)
388 } else if jumped || (ask.stick && memo.at_end) {
389 ScrollMove::End
390 } else if ask.fresh {
391 ScrollMove::Restore(memo.offset_y)
392 } else {
393 ScrollMove::Stay
394 }
395}
396
397/// What to tell the client now that this list is at `at_end`, if anything.
398///
399/// Against what it was last told rather than against where the list was a
400/// moment ago. The two are the same answer for a list the reader is moving by
401/// hand, and they part company wherever something else moved it — see `told`.
402fn report(memo: &mut ScrollMemo, at_end: bool) -> Option<&'static str> {
403 (memo.told != Some(at_end)).then(|| {
404 memo.told = Some(at_end);
405 // "end" or "away", the strings libvidya emits: frq's handler compares
406 // against "end".
407 if at_end { "end" } else { "away" }
408 })
409}
410
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago411/// Where the rows of the scroll area called `name` were last laid out.
412///
413/// By name and not by node, because a scroll area outlives the node ids of a
414/// tree that is rebuilt under it — the same list, and the reader's place in
415/// it, is the thing `scroll-key` names.
416fn placements(name: &str) -> rows::Placements {
417 static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
418 LazyLock::new(|| Mutex::new(HashMap::new()));
419 lock(&BOOKS).entry(name.to_owned()).or_default().clone()
420}
421
422/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
423/// of a viewport `viewport` tall.
424///
425/// The middle rather than the top edge: a line answered three days ago is read
426/// with what was said around it, and a jump that pins it to the ceiling shows
427/// only what came after.
428///
429/// Never above the start of the content — a negative offset is not a place —
430/// and the top edge is the answer while the viewport's height is unknown,
431/// which it is until the reader has scrolled the list once. A row centred in a
432/// viewport of nothing is a row at the top, which is the same answer said
433/// twice, but it is worth being the one that is said on purpose.
434fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
435 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
436}
437
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago438/// Whether to say out loud what every jump decided, on stderr.
439///
440/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
441/// and which of them is wrong is not a thing anyone can tell from a window
442/// that scrolled to the wrong place.
443fn scroll_log() -> bool {
444 static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
445 *ON
446}
447
448/// How many frames a reveal keeps trying for.
449///
450/// A row is measured by the layout that draws it, so the frame a jump is asked
451/// on is a frame too early: the places written down are the ones from before
452/// the room changed. Twenty frames is a third of a second at sixty, which is
453/// longer than a screen takes to build and shorter than a reader waits before
454/// deciding nothing happened.
455const REVEAL_TRIES: u8 = 20;
456
Keep asking for the place a list opens at 8e8cd51 nandi 7d ago457/// How many frames a list that has just mounted keeps asking for the place it
458/// opens at.
459///
460/// `REVEAL_TRIES`' reasoning at the other end of the same problem. iced runs a
461/// widget operation against the tree the last `view` built, so the ask that
462/// goes out on the commit a scroll area ARRIVES on can find no such widget to
463/// act on — the scrollable it names is one this frame is only now building.
464/// Whether it lands is then a question of which happens first, which is not a
465/// question a room should open on: most of the time the conversation opened at
466/// its newest line and sometimes it opened at the top of the backlog.
467///
468/// Four frames rather than `reveal`'s twenty, and it stops the moment the list
469/// reports that it arrived. A mount settles in one or two; what is left of the
470/// budget after that is time spent fighting a reader who opened a room and
471/// immediately scrolled, and there is no reason to spend more of it than the
472/// mount actually needs.
473const SETTLE_TRIES: u8 = 4;
474
475/// Whether the list called `name` is already where a mount sent it — its end
476/// for `None`, that offset for `Some`.
477///
478/// Read off the memo, which is to say off what the toolkit last reported, so
479/// a list nobody has heard from yet has not arrived and is asked again.
480fn settled(memo: Option<&ScrollMemo>, want: Option<f32>) -> bool {
481 match (memo, want) {
482 (None, _) => false,
483 (Some(m), None) => m.at_end,
484 (Some(m), Some(y)) => (m.offset_y - y).abs() <= AT_END_SLACK,
485 }
486}
487
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago488/// The row of the scroll area called `name` that is asking to be shown, as the
489/// tree has it now.
490///
491/// Asked again on every attempt rather than carried, because a row is not the
492/// same node for long. A buffer that takes a line while a jump is landing is
493/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
494/// node 587 now — so a retry holding the old number would look up a place for
495/// a row nobody has, and go on failing until it gave up. Which room a reader
496/// jumped into decided whether it worked, and that is exactly as strange as
497/// it sounds until you see what it depends on.
498fn asking_row(t: &Tree, name: &str) -> Option<i32> {
499 let mut found = None;
500 walk(t, t.root_id(), &mut |id, n| {
501 if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
502 return;
503 }
504 for row in &n.children {
505 let mut asked = false;
506 walk(t, *row, &mut |_, node| {
507 asked |= node.bool("scroll-here") == Some(true);
508 });
509 if asked {
510 found = Some(*row);
511 break;
512 }
513 }
514 });
515 found
516}
517
518/// Ask to be taken to the row of `name` that wants showing — now if its place
519/// is known, and on the next frame if it is not.
520///
521/// A task that is already finished is not a wasted frame: iced takes its
522/// message on the next pass of the loop, which is after this frame has been
523/// laid out — and being laid out is exactly what the row has to have done for
524/// there to be an answer.
525fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
526 match placements(&name).get(row) {
527 Some((top, height)) => {
528 let y = centred_offset(top, height, viewport);
529 if scroll_log() {
530 eprintln!(
531 "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
532 );
533 }
534 iced_scrollable::scroll_to(scroll_id(&name), AbsoluteOffset { x: None, y: Some(y) })
535 }
536 None => {
537 if scroll_log() {
538 eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
539 }
540 Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
541 }
542 }
543}
544
545/// How many rows the scroll area called `name` has, and how many of them are
546/// asking to be shown. For the log alone.
547fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
548 let mut shape = (0, 0);
549 walk(t, t.root_id(), &mut |id, n| {
550 if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
551 return;
552 }
553 shape.0 = n.children.len();
554 for row in &n.children {
555 let mut asked = false;
556 walk(t, *row, &mut |_, node| {
557 asked |= node.bool("scroll-here") == Some(true);
558 });
559 if asked {
560 shape.1 += 1;
561 }
562 }
563 });
564 shape
565}
566
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago567fn snap_to_end(name: &str) -> Task<Message> {
568 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
569}
570
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago571// --- the app -----------------------------------------------------------------
572
573struct App {
574 core: Core,
575 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago576 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago577 /// What the window last wrote back into a control, by node and prop, with
578 /// the sequence number of the event that carried it to the worker.
579 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago580}
581
582#[derive(Clone, Debug)]
583enum Message {
584 Tree,
585 Quit,
586 Click(i32),
587 Toggled(i32, bool),
588 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago589 Paste(i32, String),
590 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago591 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago592 Hover(i32),
593 Unhover(i32),
594 Scrolled(i32, String, Viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago595 /// Show whichever row of this scroll area is asking to be shown, and how
596 /// many more frames to keep trying for. See `reveal`.
597 Reveal(String, u8),
Keep asking for the place a list opens at 8e8cd51 nandi 7d ago598 /// Put this scroll area where it opened at — its end for `None`, that
599 /// offset for `Some` — again, and how many more frames to keep at it.
600 /// See `SETTLE_TRIES`.
601 Settle(String, Option<f32>, u8),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago602 PickImage,
603 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago604}
605
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago606/// Lay what was typed over a commit that has not caught up with it.
607///
608/// libcosmic paints a control from the tree, so a commit rendered before the
609/// worker saw the latest keystroke would put the older text back under the
610/// caret, and the next key would land on that. An entry is let go once a
611/// commit was rendered after its event: from then on the component's own
612/// state is the answer, a draft it cleared included.
613fn keep_typed(tree: &mut Arc<Tree>, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64) {
614 typed.retain(|&(node, key), (seq, value)| {
615 let Some(n) = tree.get(node) else { return false };
616 if *seq <= settled {
617 return false;
618 }
619 if n.props.get(key) != Some(value) {
620 Arc::make_mut(tree).set(node, key, value.clone());
621 }
622 true
623 });
624}
625
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago626impl App {
627 /// A widget does not own its value: the new state goes into the arena and
628 /// 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 ago629 /// working control, and its next render is what settles it. Then the event
630 /// goes to the worker, and what was written is held over any commit
631 /// rendered before the worker saw it.
632 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 ago633 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 ago634 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
635 let seq = post_seq(node, event, text, num);
636 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago637 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago638
639 /// Take the committed tree, and move every scroll area to where it should
640 /// be now that it has changed.
641 ///
642 /// A snap is relative, so a list snapped to its end stays at its end as
643 /// rows arrive under it, until the reader scrolls away.
644 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago645 let (committed, settled) = {
646 let c = lock(&COMMITTED);
647 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
648 };
649 let before = std::mem::replace(&mut self.tree, committed);
650 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago651 let mut tasks = Vec::new();
652 let mut live = HashSet::new();
653 for ask in scroll_asks(&before, &self.tree) {
654 live.insert(ask.name.clone());
655 let memo = self
656 .scrolls
657 .entry(ask.name.clone())
658 .or_insert(ScrollMemo {
659 at_end: ask.stick,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago660 told: None,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago661 offset_y: 0.0,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago662 height: 0.0,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago663 });
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago664 // A row asking to be shown is measured on the frame it appears,
665 // so the ask stands until the layout has a place for it — see
666 // `reveal`, which asks again rather than giving up.
Keep asking for the place a list opens at 8e8cd51 nandi 7d ago667 let moved = scroll_move(&ask, memo);
668 match moved {
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago669 ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)),
670 ScrollMove::End => tasks.push(snap_to_end(&ask.name)),
671 ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to(
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago672 scroll_id(&ask.name),
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago673 AbsoluteOffset { x: None, y: Some(y) },
674 )),
675 ScrollMove::Stay => {}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago676 }
Keep asking for the place a list opens at 8e8cd51 nandi 7d ago677 // And keeps asking, while this is the commit the list arrived on:
678 // there may be no widget to have heard the ask above. `reveal`
679 // does its own asking again; the other two are asked for here.
680 if ask.fresh {
681 let want = match moved {
682 ScrollMove::End => Some(None),
683 ScrollMove::Restore(y) => Some(Some(y)),
684 _ => None,
685 };
686 if let Some(want) = want {
687 let name = ask.name.clone();
688 tasks.push(Task::future(async move {
689 cosmic::Action::App(Message::Settle(name, want, SETTLE_TRIES))
690 }));
691 }
692 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago693 }
694 // A list that was never scrolled keeps no memo worth the space; one
695 // that was keeps its place for when it comes back.
696 self.scrolls
697 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
698 Task::batch(tasks)
699 }
700
701 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
702 let y = viewport.absolute_offset().y;
703 let room = viewport.content_bounds().height - viewport.bounds().height;
704 let at_end = room - y <= AT_END_SLACK;
705 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
706 at_end,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago707 told: None,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago708 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago709 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago710 });
711 memo.at_end = at_end;
712 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago713 memo.height = viewport.bounds().height;
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago714 if let Some(place) = report(memo, at_end) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago715 post(node, "change", place.to_owned(), 0.0);
716 }
717 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago718}
719
720impl cosmic::Application for App {
721 type Executor = cosmic::executor::Default;
722 type Flags = String;
723 type Message = Message;
724 const APP_ID: &'static str = "dev.jolt.Glimmer";
725
726 fn core(&self) -> &Core {
727 &self.core
728 }
729
730 fn core_mut(&mut self) -> &mut Core {
731 &mut self.core
732 }
733
734 fn init(core: Core, title: String) -> (Self, Task<Message>) {
735 let mut app = App {
736 core,
737 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago738 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago739 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago740 };
741 // libcosmic's `wayland` feature brings `multi-window` with it, which
742 // makes a window title a per-window thing.
743 app.set_header_title(title.clone());
744 let task = match app.core.main_window_id() {
745 Some(id) => app.set_window_title(title, id),
746 None => Task::none(),
747 };
748 (app, task)
749 }
750
751 fn subscription(&self) -> Subscription<Message> {
752 Subscription::run(wakes)
753 }
754
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago755 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
756 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
757 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
758 }
759
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago760 fn update(&mut self, message: Message) -> Task<Message> {
761 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago762 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago763 Message::Quit => return cosmic::iced::exit(),
764 Message::Click(node) => post(node, "click", String::new(), 0.0),
765 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago766 let num = f64::from(u8::from(on));
767 self.write_back(node, "active", Prop::Bool(on), "toggled", String::new(), num);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago768 }
769 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago770 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
771 }
772 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
773 // clipboard holding a picture has none, so the field comes back as
774 // it was. That is the paste worth reporting: the picture is read
775 // here, where the clipboard is, and `paste-empty` goes to the
776 // worker, which collects it with `cosmic_clipboard_image_png`.
777 Message::Paste(node, text) => {
778 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
779 return cosmic::iced::clipboard::read_data::<ClipboardPng>()
780 .map(move |png| cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))));
781 }
782 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
783 }
784 Message::PastedPicture(node, png) => {
785 *lock(&CLIPBOARD_PNG) = png;
786 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago787 }
788 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago789 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
790 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
791 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago792 // The row was not laid out when the jump was asked for. Look
793 // again, and keep looking for a few frames: a room the reader has
794 // only just been taken to has to be built before its lines have
795 // anywhere to be.
Keep asking for the place a list opens at 8e8cd51 nandi 7d ago796 // Asked again until the list says it arrived, or the budget is
797 // out. Idempotent either way: both asks name an absolute place,
798 // so one that already landed lands on the same place again.
799 Message::Settle(name, want, tries) => {
800 if settled(self.scrolls.get(&name), want) || tries == 0 {
801 return Task::none();
802 }
803 let again = {
804 let name = name.clone();
805 Task::future(async move {
806 cosmic::Action::App(Message::Settle(name, want, tries - 1))
807 })
808 };
809 let now = match want {
810 None => snap_to_end(&name),
811 Some(y) => iced_scrollable::scroll_to(
812 scroll_id(&name),
813 AbsoluteOffset { x: None, y: Some(y) },
814 ),
815 };
816 return Task::batch([now, again]);
817 }
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago818 Message::Reveal(name, tries) => {
819 let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
820 let place = asking_row(&self.tree, &name)
821 .and_then(|row| placements(&name).get(row));
822 if let Some((top, height)) = place {
Leave the end flag to the thing that can see the end e39a374 nandi 7d ago823 // Nothing written down here either, for the reason the
824 // commit path gives: where this ends up is `scrolled`'s to
825 // report, and its report is what the client hears.
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago826 let y = centred_offset(top, height, viewport);
827 return iced_scrollable::scroll_to(
828 scroll_id(&name),
829 AbsoluteOffset { x: None, y: Some(y) },
830 );
831 }
832 if scroll_log() {
833 let asking = asking_row(&self.tree, &name);
834 let (rows, here) = scroll_shape(&self.tree, &name);
835 eprintln!(
836 "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
837 {rows} rows, {here} asking to be shown, \
838 {} placed, viewport {viewport}",
839 placements(&name).len()
840 );
841 }
842 // Not landed yet. Keep trying for the whole budget rather
843 // than stopping the moment nothing is asking: a room the
844 // reader has just been taken to is built over several frames,
845 // and one where the rows are not in the tree yet looks exactly
846 // like a jump that is over. It is not over, it is early.
847 if tries > 0 {
848 return Task::future(async move {
849 cosmic::Action::App(Message::Reveal(name, tries - 1))
850 });
851 }
852 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago853 // The desktop's own chooser, through the portal, on libcosmic's
854 // executor: it is a D-Bus round trip, and the window keeps
855 // painting while it is open.
856 Message::PickImage => {
857 return Task::perform(
858 async {
859 rfd::AsyncFileDialog::new()
860 .set_title("Choose a picture")
861 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
862 .pick_file()
863 .await
864 .map(|file| file.path().to_path_buf())
865 },
866 |path| cosmic::Action::App(Message::Picked(path)),
867 );
868 }
869 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago870 }
871 Task::none()
872 }
873
874 fn view(&self) -> Element<'_, Message> {
875 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago876 let root = element(tree, tree.root_id(), true, false);
877 // A dialog that asked not to be modal, put up here rather than handed
878 // to `dialog` below. It is the same widget in the same place — a
879 // `popover` centres it exactly as `cosmic::app` does — and the whole
880 // of the difference is that this one is not told to intercept the
881 // pointer. That matters to anything the pointer opened: a modal
882 // popover hands the window underneath it a cursor that is
883 // `Unavailable`, so a face that opened a dialog on hover never hears
884 // the pointer leave, and what it opened can never close itself.
885 //
886 // The popover is here whether or not there is anything in it, which
887 // `cosmic::app` says of its own in one line and which this learned
888 // the long way: iced keeps a widget's state by where it sits in the
889 // tree, so a wrapper that comes and goes rebuilds everything under
890 // it — and what "everything" holds is the scroll positions. Wrapping
891 // only when a dialog appeared meant resting the pointer on a face
892 // jumped the conversation behind it.
893 let mut popover = widget::popover(root);
894 if let Some(id) = find_dialog(tree, false) {
895 // The dialog reports its own pointer, on the same two events a
896 // face or a pill reports theirs. Without it a dialog the pointer
897 // opened can only be read at arm's length: the client is told the
898 // pointer left what opened it and never told it arrived here, so
899 // the one way to keep it up is not to move — and everything in it
900 // is out of reach.
901 let popup = widget::mouse_area(dialog_of(tree, id))
902 .on_enter(Message::Hover(id))
903 .on_exit(Message::Unhover(id));
904 popover = popover.popup(popup);
905 }
906 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago907 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago908
909 /// The MODAL dialog the tree is carrying, if it is carrying one.
910 ///
911 /// A client says there is one by putting a `dialog` node in the tree and
912 /// says there is not by leaving it out — the same way it says anything
913 /// else. What comes back is libcosmic's own dialog: centred, over a
914 /// dimmed window, and closed by the buttons the client hung on it.
915 ///
916 /// A dialog that says `modal false` does not come back here. This hook is
917 /// the modal one whether the client wants it or not — `cosmic::app` wraps
918 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
919 /// that kind up itself. See `dialog_of`.
920 fn dialog(&self) -> Option<Element<'_, Message>> {
921 let tree = &*self.tree;
922 let id = find_dialog(tree, true)?;
923 Some(dialog_of(tree, id))
924 }
925}
926
927/// The first `dialog` node in the tree whose modality is `modal`.
928///
929/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
930/// not, which is the shape everything else here takes — a prop left out is
931/// the ordinary answer.
932fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
933 let mut found = None;
934 walk(t, t.root_id(), &mut |id, n| {
935 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
936 found = Some(id);
937 }
938 });
939 found
940}
941
942/// One `dialog` node as libcosmic's dialog.
943///
944/// `label` is its heading and `body` the line under it. Children are its
945/// controls, in order, except that a child carrying `slot` "primary" or
946/// "secondary" becomes that action instead — which is where libcosmic puts
947/// the buttons, at the foot and to the right.
948fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
949 let Some(n) = t.get(id) else {
950 return widget::Space::new().width(0).height(0).into();
951 };
952 let mut d = widget::dialog();
953 if !n.label().is_empty() {
954 d = d.title(n.label().to_owned());
955 }
956 if !n.str("body").is_empty() {
957 d = d.body(n.str("body").to_owned());
958 }
959 if let Some(w) = n.num("max-width") {
960 d = d.max_width(w as f32);
961 }
962 for child in &n.children {
963 let Some(c) = t.get(*child) else { continue };
964 let el = element(t, *child, true, false);
965 d = match c.str("slot") {
966 "primary" => d.primary_action(el),
967 "secondary" => d.secondary_action(el),
968 _ => d.control(el),
969 };
970 }
971 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago972}
973
974// --- props into layout -----------------------------------------------------------
975
976/// `margin` all round, with `margin-top` and its siblings overriding a side.
977fn margins(n: &Node) -> Padding {
978 let all = n.num("margin").unwrap_or(0.0) as f32;
979 let side = |key| n.num(key).map_or(all, |v| v as f32);
980 Padding {
981 top: side("margin-top"),
982 right: side("margin-right"),
983 bottom: side("margin-bottom"),
984 left: side("margin-left"),
985 }
986}
987
988/// A width the client asked for. Zero is the client saying "none": frq writes
989/// `:width-request 0` on its message column whenever the people panel is shut,
990/// and taken literally that is a backlog laid out zero points wide.
991fn width_request(n: &Node) -> Option<f32> {
992 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
993}
994
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago995/// `align`, or `default` where it is not set. A column starts its children at
996/// the left. Rows do not ask: `align` on a row is where along the row its
997/// children sit, not how they line up across it, and the row branch of
998/// `element` reads it itself.
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago999fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1000 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1001 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1002 "center" => Alignment::Center,
1003 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1004 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1005 }
1006}
1007
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1008fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
1009 cosmic::theme::Container::custom(move |_| ContainerStyle {
1010 background: Some(Background::Color(color)),
1011 border: Border {
1012 radius: radius.into(),
1013 ..Border::default()
1014 },
1015 text_color: Some(Color::WHITE),
1016 ..ContainerStyle::default()
1017 })
1018}
1019
1020/// A colour for somebody, from their name, so the same person is the same
1021/// colour everywhere they appear.
1022fn name_colour(name: &str) -> Color {
1023 const PALETTE: [(f32, f32, f32); 8] = [
1024 (0.83, 0.33, 0.33),
1025 (0.85, 0.55, 0.20),
1026 (0.62, 0.62, 0.18),
1027 (0.30, 0.65, 0.35),
1028 (0.20, 0.62, 0.62),
1029 (0.30, 0.50, 0.85),
1030 (0.55, 0.40, 0.85),
1031 (0.80, 0.35, 0.65),
1032 ];
1033 let hash = name
1034 .bytes()
1035 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
1036 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
1037 Color::from_rgb(r, g, b)
1038}
1039
1040/// A picture that answers a click, with the pointer saying so.
1041fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
1042 if !enabled {
1043 return el;
1044 }
1045 widget::mouse_area(el)
1046 .on_press(Message::Click(id))
1047 .interaction(cosmic::iced::mouse::Interaction::Pointer)
1048 .into()
1049}
1050
1051fn picture(path: &str) -> Option<widget::image::Handle> {
1052 (!path.is_empty() && std::path::Path::new(path).exists())
1053 .then(|| widget::image::Handle::from_path(path))
1054}
1055
1056// --- the tree into widgets ---------------------------------------------------------
1057
1058/// One node and everything under it, as widgets.
1059///
1060/// `enabled` is inherited: an insensitive container takes its whole subtree out
1061/// of interaction. `in_row` is whether the parent lays its children out across:
1062/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
1063/// column in a column takes the width and a column in a row does not take the
1064/// row's slack unless it says `fill-height`.
1065fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1066 let Some(n) = t.get(id) else {
1067 return Column::new().into();
1068 };
1069 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1070 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1071 // glimmer-jvui's theme spacing, where the client does not say: a list of
1072 // cards with nothing between them reads as one slab.
1073 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1074 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 ago1075
1076 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1077 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1078 .width(Length::Fill)
1079 .height(Length::Fill)
1080 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1081 // Sizes are set only where something asked for one. iced's rows and
1082 // columns take `Fill` on an axis from any child that fills it, which is
1083 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
1084 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
1085 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1086 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1087 let across = n.str("orientation") == "horizontal";
1088 if across {
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago1089 // `align` on a row is the MAIN axis, which is glimmer's
1090 // meaning and the one the shared screens are written against:
1091 // `:end` lays the children out *from* the right, so the first
1092 // child in the source is the rightmost on screen. Read as a
1093 // cross-axis gravity instead it did nothing visible but sit
1094 // the chips low, and the message heading's pair came out in
1095 // the order ✏️ ↩️ 🙂 hard against the clock rather than the
1096 // other way round against the edge — see `chat/action-chips`.
1097 let from_end = n.str("align") == "end";
1098 let mut row = if from_end {
1099 Row::with_children(children(true).collect::<Vec<_>>().into_iter().rev())
1100 } else {
1101 Row::with_children(children(true))
1102 }
1103 .spacing(spacing)
1104 .padding(margins(n))
1105 // Across the row the children still centre: a chip beside a
1106 // label sitting against the top of it is what that is for.
1107 .align_y(Alignment::Center);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1108 // A row fills the width it is in only when it or something in
1109 // it asks to; otherwise a line of buttons would spread out.
1110 match width_request(n) {
1111 Some(w) => row = row.width(w),
1112 None if fill_height => row = row.width(Length::Fill),
1113 None => {}
1114 }
1115 if fill_height {
1116 row = row.height(Length::Fill);
Put a row's `:align :end` where the row ends 6316bb1 nandi 7d ago1117 }
1118 if from_end {
1119 // iced has no main-axis alignment on a Row, so the edge is
1120 // a container's doing: it takes the width and puts the row
1121 // against the right of it. Not a leading Fill space, which
1122 // would have halved the slack with a row that already has
1123 // something filling in it — the join box beside its button
1124 // is that row, and the box is meant to take all of it.
1125 return widget::container(row)
1126 .width(Length::Fill)
1127 .align_x(Horizontal::Right)
1128 .into();
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1129 }
1130 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1131 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1132 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1133 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1134 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1135 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1136 match width_request(n) {
1137 Some(w) => column = column.width(w),
1138 None if fill_height || !in_row => column = column.width(Length::Fill),
1139 None => {}
1140 }
1141 if fill_height {
1142 column = column.height(Length::Fill);
1143 }
1144 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1145 }
1146 }
1147 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1148 let column = Column::with_children(children(false))
1149 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1150 .padding(24)
1151 .width(Length::Fill);
1152 let mut inner = widget::container(column).width(Length::Fill);
1153 if let Some(max) = n.num("max-width") {
1154 inner = inner.max_width(max as f32);
1155 }
1156 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1157 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1158 .height(Length::Fill)
1159 .into()
1160 }
1161 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1162 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 ago1163 if n.tag == "frame" && !n.label().is_empty() {
1164 column = column.push(widget::text::heading(n.label()));
1165 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1166 let card = widget::container(column.extend(children(false)))
1167 .padding(12)
1168 .class(cosmic::theme::Container::Card);
1169 match width_request(n) {
1170 Some(w) => card.width(w).into(),
1171 None if !in_row => card.width(Length::Fill).into(),
1172 None => card.into(),
1173 }
1174 }
1175 // Always fills both ways: a viewport that only fills its width asks its
1176 // column for no height, and is given none. The content is held to its
1177 // own height, since iced will not scroll content that fills the axis it
1178 // scrolls along.
1179 "scroll" => {
1180 let name = scroll_name(n, id);
1181 let content = Column::with_children(children(false))
1182 .spacing(spacing)
1183 .width(Length::Fill)
1184 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1185 // Wrapped in the thing that writes down where each row landed, so
1186 // that "take me to this line" has an answer in points — which is
1187 // the only thing a scroll area can be told. See `rows`.
1188 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1189 widget::scrollable(content)
1190 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1191 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1192 .height(Length::Fill)
1193 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1194 .into()
1195 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1196 // Word wrapping that falls back to breaking inside a word: a URL is one
1197 // word, and it otherwise runs straight past the edge of its column.
1198 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
1199 .wrapping(Wrapping::WordOrGlyph)
1200 .into(),
1201 "label" => widget::text::body(n.label())
1202 .wrapping(Wrapping::WordOrGlyph)
1203 .into(),
1204 "title" => widget::text::title3(n.label())
1205 .wrapping(Wrapping::WordOrGlyph)
1206 .into(),
1207 "title-2" => widget::text::title4(n.label())
1208 .wrapping(Wrapping::WordOrGlyph)
1209 .into(),
1210 "dim-label" => widget::text::caption(n.label())
1211 .wrapping(Wrapping::WordOrGlyph)
1212 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1213 "button" => {
1214 let button = match n.str("kind") {
1215 "primary" => widget::button::suggested(n.label()),
1216 "destructive" => widget::button::destructive(n.label()),
1217 _ => widget::button::standard(n.label()),
1218 };
1219 button
1220 .on_press_maybe(enabled.then_some(Message::Click(id)))
1221 .into()
1222 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1223 "link" => widget::button::link(n.label().to_owned())
1224 .on_press_maybe(enabled.then_some(Message::Click(id)))
1225 .into(),
1226 // A dot that says whether the thing is live, and the words beside it.
1227 "status" => {
1228 let colour = if n.bool("live") == Some(true) {
1229 Color::from_rgb(0.30, 0.72, 0.40)
1230 } else {
1231 Color::from_rgb(0.55, 0.55, 0.55)
1232 };
1233 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
1234 Row::new()
1235 .spacing(6)
1236 .align_y(Alignment::Center)
1237 .push(dot)
1238 .push(widget::text::caption(n.label()))
1239 .into()
1240 }
1241 "spinner" => {
1242 let mut row = Row::new()
1243 .spacing(8)
1244 .align_y(Alignment::Center)
1245 .push(widget::progress_bar::indeterminate_circular().size(16.0));
1246 if !n.label().is_empty() {
1247 row = row.push(widget::text::caption(n.label()));
1248 }
1249 row.into()
1250 }
1251 "emoji" => {
1252 let glyph = match n.str("emoji") {
1253 "" => n.label(),
1254 e => e,
1255 };
1256 widget::text(glyph.to_owned())
1257 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1258 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1259 .into()
1260 }
1261 // A round picture, or the initial on a colour from the name: most
1262 // 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 ago1263 //
1264 // And the three things a face is for besides being looked at. It
1265 // painted as a picture and nothing else until now: a client that
1266 // asked a face to answer a click, to report the pointer arriving, or
1267 // to carry a card under it was handed a portrait that did none of
1268 // them — so the profile behind every avatar in the window was
1269 // unreachable, and the hover card written for it never appeared.
1270 // Those are the same three things `reaction` below does, so they are
1271 // done the same way: `mouse_area` for the press and the two edges of
1272 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1273 "avatar" => {
1274 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 ago1275 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1276 Some(handle) => widget::image(handle)
1277 .width(size)
1278 .height(size)
1279 .content_fit(ContentFit::Cover)
1280 .border_radius(size / 2.0)
1281 .into(),
1282 None => {
1283 let initial: String = n
1284 .label()
1285 .trim_start_matches(|c: char| !c.is_alphanumeric())
1286 .chars()
1287 .next()
1288 .map(|c| c.to_uppercase().collect())
1289 .unwrap_or_default();
1290 widget::container(widget::text(initial).size(size * 0.45))
1291 .center(Length::Fixed(size))
1292 .class(filled(name_colour(n.label()), size / 2.0))
1293 .into()
1294 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1295 };
1296 // The hover is reported whether or not the face is enabled: it
1297 // says where the pointer is, which is true of an insensitive
1298 // picture too. The press is not — an insensitive subtree is out
1299 // of interaction, which is what `enabled` means here.
1300 let mut area = widget::mouse_area(face)
1301 .on_enter(Message::Hover(id))
1302 .on_exit(Message::Unhover(id));
1303 if enabled {
1304 area = area
1305 .on_press(Message::Click(id))
1306 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1307 }
1308 if n.children.is_empty() {
1309 area.into()
1310 } else {
1311 widget::tooltip(
1312 area,
1313 Column::with_children(children(false)).spacing(4),
1314 widget::tooltip::Position::Bottom,
1315 )
1316 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1317 }
1318 }
1319 // A pill: an emoji, how many people, and whether you are one of them.
1320 // What the client hangs under it is its hover card, shown while the
1321 // pointer is on the pill.
1322 "reaction" => {
1323 let glyph = match n.str("emoji") {
1324 "" => n.label(),
1325 e => e,
1326 };
1327 let size = n.num("size").unwrap_or(16.0) as f32;
1328 let mut content = Row::new()
1329 .spacing(4)
1330 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1331 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1332 let count = n.num("count").unwrap_or(0.0);
1333 if count > 0.0 {
1334 content = content.push(widget::text::caption(format!("{count}")));
1335 }
1336 let class = if n.bool("mine") == Some(true) {
1337 widget::button::ButtonClass::Suggested
1338 } else {
1339 widget::button::ButtonClass::Standard
1340 };
1341 let pill = widget::button::custom(content)
1342 .padding([2, 8])
1343 .class(class)
1344 .on_press_maybe(enabled.then_some(Message::Click(id)));
1345 let pill = widget::mouse_area(pill)
1346 .on_enter(Message::Hover(id))
1347 .on_exit(Message::Unhover(id));
1348 if n.children.is_empty() {
1349 pill.into()
1350 } else {
1351 widget::tooltip(
1352 pill,
1353 Column::with_children(children(false)).spacing(4),
1354 widget::tooltip::Position::Bottom,
1355 )
1356 .into()
1357 }
1358 }
1359 // One tag for both kinds of picture, as in libvidya. `feed` is live
1360 // pixels pushed under a name, which nothing pushes here yet, so it
1361 // holds the slot the layout gave it.
1362 "image" => {
1363 let max_w = n.num("max-width").map(|v| v as f32);
1364 let max_h = n.num("max-height").map(|v| v as f32);
1365 if !n.str("feed").is_empty() {
1366 let w = max_w.unwrap_or(160.0);
1367 let h = max_h.unwrap_or(w * 0.75);
1368 widget::container(widget::text::caption("video"))
1369 .center_x(Length::Fixed(w))
1370 .center_y(Length::Fixed(h))
1371 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1372 .into()
1373 } else if let Some(handle) = picture(n.str("src")) {
1374 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1375 if n.bool("fit") == Some(true) {
1376 image = image.width(Length::Fill).height(Length::Fill);
1377 } else if let Some(size) = n.num("size") {
1378 image = image.width(size as f32).height(size as f32);
1379 }
1380 let mut bounded = widget::container(image);
1381 if let Some(w) = max_w {
1382 bounded = bounded.max_width(w);
1383 }
1384 if let Some(h) = max_h {
1385 bounded = bounded.max_height(h);
1386 }
1387 clickable(bounded.into(), id, enabled)
1388 } else {
1389 widget::Space::new().width(0).height(0).into()
1390 }
1391 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1392 "checkbutton" => {
1393 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1394 if enabled {
1395 check = check.on_toggle(move |on| Message::Toggled(id, on));
1396 }
1397 check.into()
1398 }
1399 "entry" => {
1400 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1401 if enabled {
1402 entry = entry
1403 .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 ago1404 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1405 .on_submit(move |_| Message::Activate(id));
1406 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1407 let width = match width_request(n) {
1408 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1409 _ => Length::Fill,
1410 };
1411 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1412 }
1413 "separator" => widget::divider::horizontal::default().into(),
1414 "spacer" => {
1415 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1416 if n.str("expand").is_empty() {
1417 widget::Space::new().width(size).height(size).into()
1418 } else {
1419 widget::Space::new().width(Length::Fill).height(size).into()
1420 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1421 }
1422 "progress" => {
1423 let bar =
1424 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1425 if n.label().is_empty() {
1426 bar.into()
1427 } else {
1428 Column::new()
1429 .spacing(4)
1430 .push(widget::text::caption(n.label()))
1431 .push(bar)
1432 .into()
1433 }
1434 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1435 // The one node that is not painted where it stands. libcosmic puts a
1436 // dialog up itself, centred over the window and dimming what is
1437 // behind it — `Application::dialog` is the hook, and it is asked for
1438 // one separately from `view`. So the tree carries the dialog wherever
1439 // the client found it convenient to write it, `App::dialog` goes and
1440 // finds it there, and this leaves nothing behind in the layout. A
1441 // node rendered in both places would be painted twice.
1442 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1443 // Kept rather than refused, as in libvidya: a tag this backend has not
1444 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1445 _ => Column::with_children(children(false))
1446 .spacing(spacing)
1447 .padding(margins(n))
1448 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1449 };
1450
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1451 // The containers and the entry size themselves above; anything else asked
1452 // for a width gets it from a wrapper.
1453 match (n.tag.as_str(), width_request(n)) {
1454 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1455 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1456 }
1457}
1458
1459// --- the C ABI: the loop -------------------------------------------------------
1460
1461static TITLE: Mutex<String> = Mutex::new(String::new());
1462
1463/// The window's title, read when `cosmic_run` opens it. A call of its own
1464/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1465/// `cosmic_run` has to be one.
1466///
1467/// # Safety
1468/// `title` is null or a NUL-terminated string.
1469#[no_mangle]
1470pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1471 let title = borrowed(title);
1472 guard((), || *lock(&TITLE) = title)
1473}
1474
1475/// Open the window and run libcosmic until it closes. Blocks; call it on the
1476/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1477///
1478/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1479/// in this process — winit's event loop cannot be made twice.
1480#[no_mangle]
1481pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1482 let status = guard(1, || {
1483 let title = lock(&TITLE).clone();
1484 if RAN.swap(true, SeqCst) {
1485 log::error!("jolt-cosmic: a window already ran in this process");
1486 return 2;
1487 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1488 // The size asked for, until libcosmic reports the one it got.
1489 WINDOW_W.store(width.max(1) as u32, SeqCst);
1490 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1491 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1492 let mut settings = cosmic::app::Settings::default().size(size);
1493 match mode {
1494 1 => settings = settings.theme(cosmic::Theme::dark()),
1495 2 => settings = settings.theme(cosmic::Theme::light()),
1496 _ => {}
1497 }
1498 match cosmic::app::run::<App>(settings, title) {
1499 Ok(()) => 0,
1500 Err(err) => {
1501 eprintln!("jolt-cosmic: {err}");
1502 1
1503 }
1504 }
1505 });
1506 // Outside the guard, so a panic in libcosmic still releases the worker.
1507 *lock(&TO_APP) = None;
1508 CLOSED.store(true, SeqCst);
1509 BELL.notify_all();
1510 status
1511}
1512
1513/// 1 once `cosmic_run` has returned.
1514#[no_mangle]
1515pub extern "C" fn cosmic_should_close() -> c_int {
1516 c_int::from(CLOSED.load(SeqCst))
1517}
1518
1519/// Close the window. Asked before the window exists, it closes on opening.
1520#[no_mangle]
1521pub extern "C" fn cosmic_quit() {
1522 guard((), || {
1523 QUIT_ASKED.store(true, SeqCst);
1524 tell_app(Wake::Quit);
1525 })
1526}
1527
1528/// Publish the edits since the last commit. Answers 1 when there were any.
1529#[no_mangle]
1530pub extern "C" fn cosmic_tree_commit() -> c_int {
1531 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1532 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1533 let snapshot = {
1534 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1535 // A pass that only settled events still publishes, so a control
1536 // holding typed text over an older commit lets go of it.
1537 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1538 return 0;
1539 }
1540 e.dirty = false;
1541 Arc::new(e.tree.clone())
1542 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1543 {
1544 let mut committed = lock(&COMMITTED);
1545 *committed = snapshot;
1546 COMMITTED_SETTLED.store(settled, SeqCst);
1547 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1548 tell_app(Wake::Tree);
1549 1
1550 })
1551}
1552
1553/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1554/// closing. Answers 1 when an event is waiting.
1555#[no_mangle]
1556pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1557 guard(0, || {
1558 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1559 let (mut inbox, _) = BELL
1560 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1561 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1562 })
1563 .unwrap_or_else(|poisoned| poisoned.into_inner());
1564 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1565 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1566 c_int::from(!inbox.queue.is_empty())
1567 })
1568}
1569
1570/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1571#[no_mangle]
1572pub extern "C" fn cosmic_wake() {
1573 guard((), || {
1574 lock(&INBOX).woken = true;
1575 BELL.notify_all();
1576 })
1577}
1578
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1579// --- the C ABI: the window and the desktop -----------------------------------------
1580
1581/// The window's width in points; the size asked for until it has opened.
1582#[no_mangle]
1583pub extern "C" fn cosmic_window_width() -> c_int {
1584 WINDOW_W.load(SeqCst) as c_int
1585}
1586
1587#[no_mangle]
1588pub extern "C" fn cosmic_window_height() -> c_int {
1589 WINDOW_H.load(SeqCst) as c_int
1590}
1591
1592/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1593/// there is no window to ask from; the choice arrives through
1594/// `cosmic_picked_image`.
1595#[no_mangle]
1596pub extern "C" fn cosmic_pick_image() -> c_int {
1597 guard(0, || {
1598 if lock(&TO_APP).is_none() {
1599 return 0;
1600 }
1601 *lock(&PICK) = Pick::Open;
1602 tell_app(Wake::PickImage);
1603 1
1604 })
1605}
1606
1607/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1608/// was chosen since the last call; 0 while the chooser is open, after it was
1609/// cancelled, or when the picture could not be read.
1610///
1611/// # Safety
1612/// `path` is null or a NUL-terminated string.
1613#[no_mangle]
1614pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1615 let path = borrowed(path);
1616 guard(0, || {
1617 let chosen = {
1618 let mut pick = lock(&PICK);
1619 match std::mem::replace(&mut *pick, Pick::Idle) {
1620 Pick::Chosen(chosen) => chosen,
1621 other => {
1622 *pick = other;
1623 return 0;
1624 }
1625 }
1626 };
1627 match image::open(&chosen).and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) {
1628 Ok(()) => 1,
1629 Err(err) => {
1630 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1631 0
1632 }
1633 }
1634 })
1635}
1636
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1637/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1638/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1639/// already taken, or when the file could not be written.
1640///
1641/// # Safety
1642/// `path` is null or a NUL-terminated string.
1643#[no_mangle]
1644pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1645 let path = borrowed(path);
1646 guard(0, || {
1647 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1648 return 0;
1649 };
1650 match std::fs::write(&*path, png) {
1651 Ok(()) => 1,
1652 Err(err) => {
1653 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1654 0
1655 }
1656 }
1657 })
1658}
1659
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1660// --- the C ABI: events -----------------------------------------------------------
1661
1662static EVENT_NAME: Scratch = Scratch::new();
1663static EVENT_TEXT: Scratch = Scratch::new();
1664
1665/// Dequeue one event; 1 while there was one. The accessors describe it.
1666#[no_mangle]
1667pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1668 guard(0, || {
1669 let mut inbox = lock(&INBOX);
1670 let next = inbox.queue.pop_front();
1671 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1672 if let Some(e) = &next {
1673 inbox.taken = e.seq;
1674 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1675 inbox.current = next;
1676 c_int::from(got)
1677 })
1678}
1679
1680#[no_mangle]
1681pub extern "C" fn cosmic_tree_event_node() -> c_int {
1682 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1683}
1684
1685#[no_mangle]
1686pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1687 guard(empty_str(), || {
1688 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1689 })
1690}
1691
1692#[no_mangle]
1693pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1694 guard(empty_str(), || {
1695 let text = lock(&INBOX)
1696 .current
1697 .as_ref()
1698 .map(|e| e.text.clone())
1699 .unwrap_or_default();
1700 EVENT_TEXT.lend(text)
1701 })
1702}
1703
1704#[no_mangle]
1705pub extern "C" fn cosmic_tree_event_num() -> f64 {
1706 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1707}
1708
1709// --- the C ABI: nodes --------------------------------------------------------------
1710
1711static PROPS: Scratch = Scratch::new();
1712static DUMP: Scratch = Scratch::new();
1713
1714#[no_mangle]
1715pub extern "C" fn cosmic_tree_root() -> c_int {
1716 guard(0, || edit(Tree::root))
1717}
1718
1719/// # Safety
1720/// `tag` is null or a NUL-terminated string.
1721#[no_mangle]
1722pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1723 let tag = borrowed(tag);
1724 guard(0, || edit(|t| t.new_node(&tag)))
1725}
1726
1727#[no_mangle]
1728pub extern "C" fn cosmic_node_free(node: c_int) {
1729 guard((), || edit(|t| t.free(node)))
1730}
1731
1732#[no_mangle]
1733pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1734 guard(0, || c_int::from(read(|t| t.exists(node))))
1735}
1736
1737/// # Safety
1738/// `key` and `value` are null or NUL-terminated strings.
1739#[no_mangle]
1740pub unsafe extern "C" fn cosmic_node_set_str(
1741 node: c_int,
1742 key: *const c_char,
1743 value: *const c_char,
1744) {
1745 let (key, value) = (borrowed(key), borrowed(value));
1746 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1747}
1748
1749/// # Safety
1750/// `key` is null or a NUL-terminated string.
1751#[no_mangle]
1752pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1753 let key = borrowed(key);
1754 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1755}
1756
1757/// # Safety
1758/// `key` is null or a NUL-terminated string.
1759#[no_mangle]
1760pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1761 let key = borrowed(key);
1762 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1763}
1764
1765#[no_mangle]
1766pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1767 guard((), || edit(|t| t.clear_props(node)))
1768}
1769
1770#[no_mangle]
1771pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1772 guard(empty_str(), || {
1773 PROPS.lend(read(|t| {
1774 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1775 }))
1776 })
1777}
1778
1779#[no_mangle]
1780pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1781 guard(0, || {
1782 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1783 })
1784}
1785
1786#[no_mangle]
1787pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1788 guard(0, || {
1789 read(|t| {
1790 t.get(node)
1791 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1792 .unwrap_or(0)
1793 })
1794 })
1795}
1796
1797#[no_mangle]
1798pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1799 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1800}
1801
1802/// Unparents AND frees `child` with everything under it.
1803#[no_mangle]
1804pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1805 guard((), || edit(|t| t.remove(parent, child)))
1806}
1807
1808#[no_mangle]
1809pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1810 guard(0, || {
1811 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1812 })
1813}
1814
1815#[no_mangle]
1816pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1817 guard(0, || {
1818 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1819 })
1820}
1821
1822/// The subtree at `node` as hiccup; 0 is the root.
1823#[no_mangle]
1824pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1825 guard(empty_str(), || {
1826 DUMP.lend(read(|t| {
1827 let id = if node == 0 { t.root_id() } else { node };
1828 t.dump(id)
1829 }))
1830 })
1831}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1832
1833#[cfg(test)]
1834mod tests {
1835 use super::*;
1836
1837 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1838 let id = t.new_node(tag);
1839 assert!(t.append(parent, id));
1840 id
1841 }
1842
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1843 #[test]
1844 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1845 let mut t = Tree::default();
1846 let root = t.root();
1847 let entry = node(&mut t, root, "entry");
1848 t.set(entry, "text", Prop::Str("a".into()));
1849 let mut tree = Arc::new(t);
1850 let mut typed = HashMap::new();
1851 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1852
1853 // Rendered before the worker saw the "b".
1854 keep_typed(&mut tree, &mut typed, 1);
1855 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1856 assert_eq!(typed.len(), 1);
1857
1858 // Rendered after: the component cleared its draft, and that stands.
1859 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1860 keep_typed(&mut tree, &mut typed, 2);
1861 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1862 assert!(typed.is_empty());
1863 }
1864
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1865 #[test]
1866 fn a_zero_width_request_is_no_request() {
1867 let mut t = Tree::default();
1868 let root = t.root();
1869 let column = node(&mut t, root, "vbox");
1870 t.set(column, "width-request", Prop::Num(0.0));
1871 assert_eq!(width_request(t.get(column).unwrap()), None);
1872 t.set(column, "width-request", Prop::Num(260.0));
1873 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1874 }
1875
1876 #[test]
1877 fn a_scroll_is_named_by_its_scroll_key() {
1878 let mut t = Tree::default();
1879 let root = t.root();
1880 let list = node(&mut t, root, "scroll");
1881 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1882 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1883 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1884 }
1885
1886 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1887 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1888 // A row halfway down a long backlog, in a 600pt viewport: half the
1889 // viewport above it, less half the row.
1890 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1891 // The same row with no viewport reported yet: its own top.
1892 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1893 // A row near the top cannot be centred without scrolling above the
1894 // content, and nothing is above the content.
1895 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1896 // A row taller than the viewport is shown from its own top: there is
1897 // no middle of it to put in the middle.
1898 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1899 }
1900
1901 #[test]
1902 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1903 let mut t = Tree::default();
1904 let root = t.root();
1905 let list = node(&mut t, root, "scroll");
1906 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1907 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1908 let before = t.clone();
1909 t.set(rows[3], "scroll-here", Prop::Bool(true));
1910
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1911 // The row, since a row is what has a place written down for it.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1912 let asks = scroll_asks(&before, &t);
1913 assert_eq!(asks.len(), 1);
1914 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1915 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1916
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1917 // And it goes on asking while the row is still asking: the row may not
1918 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1919 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1920 assert_eq!(again[0].reveal, Some(rows[3]));
1921
1922 // A node asking from deeper inside a row answers with the row.
1923 t.set(rows[3], "scroll-here", Prop::Bool(false));
1924 let inner = node(&mut t, rows[1], "vbox");
1925 t.set(inner, "scroll-here", Prop::Bool(true));
1926 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1927
1928 // Nothing asking, nothing to reveal.
1929 t.set(inner, "scroll-here", Prop::Bool(false));
1930 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1931 }
1932
Tell the client where a scroll area landed, not where it changed 4f920af nandi 7d ago1933 /// An ask for the scroll area called `name`, with nothing going on.
1934 fn ask(name: &str) -> ScrollAsk {
1935 ScrollAsk {
1936 name: name.to_owned(),
1937 stick: true,
1938 tick: Some(0.0),
1939 tick_before: Some(0.0),
1940 fresh: false,
1941 reveal: None,
1942 }
1943 }
1944
1945 fn memo() -> ScrollMemo {
1946 ScrollMemo { at_end: true, told: Some(true), offset_y: 0.0, height: 600.0 }
1947 }
1948
1949 #[test]
1950 fn a_jump_asks_for_the_end_without_saying_it_arrived() {
1951 let mut m = memo();
1952 m.at_end = false;
1953 m.told = Some(false);
1954 let mut a = ask("backlog");
1955 a.tick = Some(1.0);
1956 assert_eq!(scroll_move(&a, &mut m), ScrollMove::End);
1957 // The memo still says what the last report said, so the report that
1958 // comes back from the toolkit is a change, and is passed on. A memo
1959 // that marked itself here would swallow it, and the client would go
1960 // on believing the reader was away from the newest line.
1961 assert!(!m.at_end);
1962 assert_eq!(report(&mut m, true), Some("end"));
1963 assert_eq!(report(&mut m, true), None);
1964 }
1965
1966 #[test]
1967 fn a_list_that_has_just_mounted_says_where_it_is() {
1968 // The reader left this list away from the end, and the client was
1969 // told so. It comes back — another room under the same widget, or the
1970 // same room after the lightbox took the screen — and lands at the end
1971 // because it sticks there. Nothing about `at_end` CHANGED across
1972 // that, and the client still has to hear it: what it believes is
1973 // about the list this one replaced.
1974 let mut m = memo();
1975 m.at_end = true;
1976 m.told = Some(false);
1977 let mut a = ask("backlog");
1978 a.fresh = true;
1979 assert_eq!(scroll_move(&a, &mut m), ScrollMove::End);
1980 assert_eq!(report(&mut m, true), Some("end"));
1981 }
1982
1983 #[test]
1984 fn a_list_nobody_moved_is_not_reported_twice() {
1985 let mut m = memo();
1986 assert_eq!(report(&mut m, true), None);
1987 assert_eq!(report(&mut m, false), Some("away"));
1988 assert_eq!(report(&mut m, false), None);
1989 assert_eq!(report(&mut m, true), Some("end"));
1990 }
1991
1992 #[test]
1993 fn a_row_asking_to_be_shown_beats_the_end() {
1994 let mut m = memo();
1995 let mut a = ask("backlog");
1996 a.reveal = Some(42);
1997 a.tick = Some(1.0);
1998 assert_eq!(scroll_move(&a, &mut m), ScrollMove::Reveal(42));
1999 }
2000
2001 #[test]
2002 fn a_list_coming_back_is_put_where_it_was_left() {
2003 let mut m = memo();
2004 m.at_end = false;
2005 m.offset_y = 512.0;
2006 let mut a = ask("backlog");
2007 a.fresh = true;
2008 assert_eq!(scroll_move(&a, &mut m), ScrollMove::Restore(512.0));
2009 // And it is asked about again, since the client's belief is about
2010 // whatever was under this name before.
2011 assert_eq!(m.told, None);
2012 assert_eq!(report(&mut m, false), Some("away"));
2013 }
2014
Keep asking for the place a list opens at 8e8cd51 nandi 7d ago2015 #[test]
2016 fn a_mount_keeps_asking_until_the_list_says_it_arrived() {
2017 // Nothing heard from yet: the ask above may have found no widget to
2018 // act on, so it stands.
2019 assert!(!settled(None, None));
2020 assert!(!settled(None, Some(512.0)));
2021
2022 // Reported somewhere else: still asking.
2023 let mut m = memo();
2024 m.at_end = false;
2025 m.offset_y = 0.0;
2026 assert!(!settled(Some(&m), None));
2027 assert!(!settled(Some(&m), Some(512.0)));
2028
2029 // Arrived, and the asking stops.
2030 m.at_end = true;
2031 assert!(settled(Some(&m), None));
2032 m.offset_y = 512.0;
2033 assert!(settled(Some(&m), Some(512.0)));
2034 // Within the slack a fractional offset leaves.
2035 m.offset_y = 511.0;
2036 assert!(settled(Some(&m), Some(512.0)));
2037 m.offset_y = 480.0;
2038 assert!(!settled(Some(&m), Some(512.0)));
2039 }
2040
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago2041 #[test]
2042 fn a_jump_is_the_counter_moving() {
2043 let mut t = Tree::default();
2044 let root = t.root();
2045 let list = node(&mut t, root, "scroll");
2046 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
2047 let before = t.clone();
2048 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
2049 let asks = scroll_asks(&before, &t);
2050 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
2051 }
2052}