nandi/jolt-nativepublic Fork 0
main
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 · 2113 lines · 80.3 KBRust Blame HistoryRaw
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1//! glimmer's libcosmic backend: the retained-tree ABI, with iced reading it.
2//!
3//! The edit half is libvidya's — integer node handles, string-keyed props,
4//! events queued and polled — so `glimmer-cosmic` is `glimmer-vidya` pointed
5//! at a different object. What changes is who owns the loop.
6//!
7//! egui lets its caller drive frames; iced does not. `cosmic::app::run` takes
8//! the main thread (winit insists) and returns when the window closes. So the
9//! arrangement is inverted:
10//!
11//! * `cosmic_run` blocks the process main thread inside libcosmic.
12//! * jolt reconciles on a worker thread, mutating the arena under a mutex.
13//! Nothing it does is visible until `cosmic_tree_commit`, which snapshots the
14//! tree and wakes iced — so a reconcile half-way through a patch is never
15//! painted, and a commit with no edits behind it costs nothing.
16//! * Interactions are queued, and `cosmic_wait` blocks the worker until there
17//! is one (or `cosmic_wake`, or a timeout), so an idle window burns no CPU on
18//! either side.
19//!
20//! Every call except `cosmic_run` may come from any thread.
21
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 6d 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;
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago39use cosmic::iced::widget::scrollable::{
40 self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport,
41};
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago42use cosmic::iced::widget::text::Wrapping;
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago43use cosmic::iced::{
44 Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription,
45};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago46use cosmic::widget::{self, Column, Row};
47use cosmic::{ApplicationExt, Element};
48use jolt_abi::{borrowed, empty_str, guard, Scratch};
49
50fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
51 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
52}
53
54// --- the arena ---------------------------------------------------------------
55
56struct Edits {
57 tree: Tree,
58 /// Set by every mutation, cleared by a commit that published it.
59 dirty: bool,
60}
61
62static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
63 Mutex::new(Edits {
64 tree: Tree::default(),
65 dirty: false,
66 })
67});
68
69/// What `view` paints: the tree as of the last commit.
70static 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 ago71/// The inbox's `settled` when that commit was made. Written under
72/// `COMMITTED`'s lock, so the two are read as a pair.
73static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago74
75fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
76 let mut e = lock(&EDITS);
77 e.dirty = true;
78 f(&mut e.tree)
79}
80
81fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
82 f(&lock(&EDITS).tree)
83}
84
85// --- events, towards jolt ----------------------------------------------------
86
87struct Event {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago88 seq: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago89 node: i32,
90 name: &'static str,
91 text: String,
92 num: f64,
93}
94
95struct Inbox {
96 queue: VecDeque<Event>,
97 current: Option<Event>,
98 woken: bool,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago99 /// The sequence number of the last event posted.
100 posted: u64,
101 /// The sequence number of the last event the worker dequeued.
102 taken: u64,
103 /// `taken` as of the worker's last `cosmic_wait`. Every event up to here
104 /// had its handler run on an earlier pass, so whatever it re-rendered is
105 /// in the arena by the next commit.
106 settled: u64,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago107}
108
109static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
110 queue: VecDeque::new(),
111 current: None,
112 woken: false,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago113 posted: 0,
114 taken: 0,
115 settled: 0,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago116});
117static BELL: Condvar = Condvar::new();
118
119fn 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 ago120 post_seq(node, name, text, num);
121}
122
123/// Queue an event for the worker; answers its sequence number.
124fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 {
125 let seq = {
126 let mut inbox = lock(&INBOX);
127 inbox.posted += 1;
128 let seq = inbox.posted;
129 inbox.queue.push_back(Event {
130 seq,
131 node,
132 name,
133 text,
134 num,
135 });
136 seq
137 };
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago138 BELL.notify_all();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago139 seq
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago140}
141
142// --- wakes, towards iced -----------------------------------------------------
143
144enum Wake {
145 Tree,
146 Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago147 PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago148}
149
150static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
151static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
152static RAN: AtomicBool = AtomicBool::new(false);
153static CLOSED: AtomicBool = AtomicBool::new(false);
154
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago155/// The window's size in points, as libcosmic last reported it. A client that
156/// lays columns out by arithmetic — frq sizes its message list against the
157/// people panel beside it — has to be able to ask.
158static WINDOW_W: AtomicU32 = AtomicU32::new(0);
159static WINDOW_H: AtomicU32 = AtomicU32::new(0);
160
161/// Where a picture chooser opened by `cosmic_pick_image` has got to.
162enum Pick {
163 Idle,
164 Open,
165 Chosen(PathBuf),
166}
167
168static PICK: Mutex<Pick> = Mutex::new(Pick::Idle);
169
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago170/// The picture a Ctrl+V found on the clipboard, held until the worker asks for
171/// it with `cosmic_clipboard_image_png`.
172static CLIPBOARD_PNG: Mutex<Option<Vec<u8>>> = Mutex::new(None);
173
174/// The clipboard read as PNG. Only image/png is asked for: every desktop that
175/// puts a picture on a clipboard puts one there as PNG too.
176struct ClipboardPng(Vec<u8>);
177
178impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng {
179 fn allowed() -> std::borrow::Cow<'static, [String]> {
180 std::borrow::Cow::Owned(vec!["image/png".to_owned()])
181 }
182}
183
184impl TryFrom<(Vec<u8>, String)> for ClipboardPng {
185 type Error = ();
186
187 fn try_from((bytes, _mime): (Vec<u8>, String)) -> Result<Self, ()> {
188 if bytes.is_empty() {
189 Err(())
190 } else {
191 Ok(Self(bytes))
192 }
193 }
194}
195
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago196/// Named for emoji rather than left to fallback: the first face with a glyph
197/// for a smiley is often a monochrome one, and the pill then shows an outline.
198const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji");
199
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago200fn tell_app(wake: Wake) {
201 if let Some(tx) = lock(&TO_APP).as_ref() {
202 let _ = tx.unbounded_send(wake);
203 }
204}
205
206/// The subscription's stream. It opens with a `Tree` wake so a commit made
207/// between `init` and the subscription starting is not missed, and repeats a
208/// quit asked for before there was anyone to tell.
209fn wakes() -> impl Stream<Item = Message> {
210 let (tx, rx) = mpsc::unbounded();
211 let _ = tx.unbounded_send(Wake::Tree);
212 if QUIT_ASKED.load(SeqCst) {
213 let _ = tx.unbounded_send(Wake::Quit);
214 }
215 *lock(&TO_APP) = Some(tx);
216 rx.map(|wake| match wake {
217 Wake::Tree => Message::Tree,
218 Wake::Quit => Message::Quit,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago219 Wake::PickImage => Message::PickImage,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago220 })
221}
222
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago223// --- scroll areas ------------------------------------------------------------
224
225/// Where a scroll area was left, kept by name rather than on the widget.
226///
227/// iced keeps a scrollable's offset in its widget tree, and a widget that is
228/// unmounted and mounted again starts at the top. glimmer clients unmount
229/// lists all the time — frq's lightbox is a screen, so looking at a picture
230/// takes the backlog away — so the place is remembered here, under the
231/// `scroll-key` the client names the list by, and put back when it returns.
232struct ScrollMemo {
233 /// Whether the reader is at the newest line. A `stick-to-bottom` list
234 /// follows what arrives only while this holds.
235 at_end: bool,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago236 /// What the client was last told about that, and `None` while it has been
237 /// told nothing.
238 ///
239 /// Held apart from `at_end` because the two answer different questions.
240 /// `at_end` is where this list is; `told` is what the client believes,
241 /// and a report is worth making exactly when they differ. Reporting on a
242 /// change in `at_end` alone loses two cases, and both of them end with a
243 /// "jump to present" button over a backlog that is already at its newest
244 /// line. A list mounting says nothing, because the place it opens at is
245 /// the place the memo guessed it would — but the client's belief is about
246 /// the list this one REPLACED, which in frq is another room entirely. And
247 /// a jump says nothing, because the branch that asked for it marked the
248 /// memo on the way past, so the report that came back agreed with it.
249 told: Option<bool>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago250 offset_y: f32,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago251 /// How tall the viewport was when the reader last moved it, which is what
252 /// a jump centres a row in. Zero until they have: a list nobody has
253 /// scrolled has no reported height, and a row put in the middle of a
254 /// viewport of nothing is a row put at the top — which is the right answer
255 /// to give when the height is not known.
256 height: f32,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago257}
258
259/// Two points of slack: a viewport scrolled to its end by a fractional
260/// offset is still at the end.
261const AT_END_SLACK: f32 = 2.0;
262
263fn scroll_name(n: &Node, id: i32) -> String {
264 match n.str("scroll-key") {
265 "" => format!("node-{id}"),
266 key => key.to_owned(),
267 }
268}
269
270fn scroll_id(name: &str) -> widget::Id {
271 widget::Id::new(format!("jolt-scroll-{name}"))
272}
273
274fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
275 if let Some(n) = t.get(id) {
276 f(id, n);
277 for child in &n.children {
278 walk(t, *child, f);
279 }
280 }
281}
282
283/// What a commit asks of one scroll area.
284struct ScrollAsk {
285 name: String,
286 stick: bool,
287 /// The `scroll-to-bottom` counter, and what it was in the tree before.
288 tick: Option<f64>,
289 tick_before: Option<f64>,
290 /// Not in the tree before this commit: mounted, or mounted again.
291 fresh: bool,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago292 /// The row asking to be shown, if one is.
293 reveal: Option<i32>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago294}
295
296/// Every scroll area in `now`, and what changed about each since `before`.
297fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
298 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
299 walk(before, before.root_id(), &mut |id, n| {
300 if n.tag == "scroll" {
301 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
302 }
303 });
304
305 let mut asks = Vec::new();
306 walk(now, now.root_id(), &mut |id, n| {
307 if n.tag != "scroll" {
308 return;
309 }
310 let name = scroll_name(n, id);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago311 // The row holding whatever asked to be shown.
312 //
313 // The row, because a row is what `rows::Rows` writes a place down for;
314 // and the row rather than a guess at where it sits, because this used
315 // to answer with its index over the row count. That is a fraction of
316 // the scroll RANGE and not of the content — the two agree only when
317 // the viewport is exactly one row tall — and it took every row for the
318 // same height besides, in a backlog that puts a one-line message next
319 // to a picture. The landing was out by up to a viewport, worst in the
320 // middle of a list.
321 //
322 // While it is asking, not only on the commit the ask arrives. A row
323 // that is not laid out yet has no place written down for it, and the
324 // ask is over in half a second: asking again each commit is what lets
325 // a jump into a conversation the client has only just switched to land
326 // on the frame the rows finally exist.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago327 let mut reveal = None;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago328 for row in &n.children {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago329 let mut asked = false;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago330 walk(now, *row, &mut |_, node| {
331 asked |= node.bool("scroll-here") == Some(true);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago332 });
333 if asked {
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago334 reveal = Some(*row);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago335 break;
336 }
337 }
338 asks.push(ScrollAsk {
339 fresh: !named_before.contains_key(&name),
340 tick_before: named_before.get(&name).copied().flatten(),
341 tick: n.num("scroll-to-bottom"),
342 stick: n.bool("stick-to-bottom") == Some(true),
343 reveal,
344 name,
345 });
346 });
347 asks
348}
349
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago350/// What a commit asks of one scroll area, decided.
351#[derive(Debug, PartialEq)]
352enum ScrollMove {
353 /// Show this row of it.
354 Reveal(i32),
355 /// Take it to its newest line.
356 End,
357 /// Put it back where the reader left it, in points.
358 Restore(f32),
359 /// Leave it alone.
360 Stay,
361}
362
363/// What to do with one scroll area, and the memo brought up to date.
364///
365/// Split out from `take_tree` because it is the whole of the thinking and none
366/// of the toolkit: everything here is the ask beside what is remembered, so it
367/// can be read — and tested — without a window to put it in.
368fn scroll_move(ask: &ScrollAsk, memo: &mut ScrollMemo) -> ScrollMove {
369 // A list this tree did not have a moment ago is a list the client has
370 // heard nothing about, whatever it heard about the last one under this
371 // name. Forgetting what it was told is what makes the next report happen,
372 // so that what it believes is about the list it is looking at — in frq,
373 // the room it is in rather than the room it came from.
374 if ask.fresh {
375 memo.told = None;
376 }
377 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
378 // A row is asking to be shown. Whether or not it can be shown yet,
379 // nothing else may move this list while it is asking: the branch below
380 // would otherwise take a reader who was at the newest line — which is
381 // most readers, most of the time — straight back to it, and a jump that
382 // ends at the bottom of the room reads as a jump that did nothing.
383 //
384 // Nothing below marks the memo, either. Where a list lands is `report`'s
385 // to hear from the toolkit and pass on; a memo that wrote the answer down
386 // here would agree with the report when it came and keep it from the
387 // client — the list moved, nobody was told, and the client went on
388 // believing whatever it believed before. Which is a "jump to present"
389 // button over a backlog that is already at its newest line.
390 if let Some(row) = ask.reveal {
391 ScrollMove::Reveal(row)
392 } else if jumped || (ask.stick && memo.at_end) {
393 ScrollMove::End
394 } else if ask.fresh {
395 ScrollMove::Restore(memo.offset_y)
396 } else {
397 ScrollMove::Stay
398 }
399}
400
401/// What to tell the client now that this list is at `at_end`, if anything.
402///
403/// Against what it was last told rather than against where the list was a
404/// moment ago. The two are the same answer for a list the reader is moving by
405/// hand, and they part company wherever something else moved it — see `told`.
406fn report(memo: &mut ScrollMemo, at_end: bool) -> Option<&'static str> {
407 (memo.told != Some(at_end)).then(|| {
408 memo.told = Some(at_end);
409 // "end" or "away", the strings libvidya emits: frq's handler compares
410 // against "end".
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago411 if at_end {
412 "end"
413 } else {
414 "away"
415 }
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago416 })
417}
418
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago419/// Where the rows of the scroll area called `name` were last laid out.
420///
421/// By name and not by node, because a scroll area outlives the node ids of a
422/// tree that is rebuilt under it — the same list, and the reader's place in
423/// it, is the thing `scroll-key` names.
424fn placements(name: &str) -> rows::Placements {
425 static BOOKS: LazyLock<Mutex<HashMap<String, rows::Placements>>> =
426 LazyLock::new(|| Mutex::new(HashMap::new()));
427 lock(&BOOKS).entry(name.to_owned()).or_default().clone()
428}
429
430/// Where to scroll so that a row at `top`, `height` tall, sits in the middle
431/// of a viewport `viewport` tall.
432///
433/// The middle rather than the top edge: a line answered three days ago is read
434/// with what was said around it, and a jump that pins it to the ceiling shows
435/// only what came after.
436///
437/// Never above the start of the content — a negative offset is not a place —
438/// and the top edge is the answer while the viewport's height is unknown,
439/// which it is until the reader has scrolled the list once. A row centred in a
440/// viewport of nothing is a row at the top, which is the same answer said
441/// twice, but it is worth being the one that is said on purpose.
442fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 {
443 (top - (viewport - height).max(0.0) / 2.0).max(0.0)
444}
445
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago446/// Whether to say out loud what every jump decided, on stderr.
447///
448/// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup,
449/// and which of them is wrong is not a thing anyone can tell from a window
450/// that scrolled to the wrong place.
451fn scroll_log() -> bool {
452 static ON: LazyLock<bool> = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some());
453 *ON
454}
455
456/// How many frames a reveal keeps trying for.
457///
458/// A row is measured by the layout that draws it, so the frame a jump is asked
459/// on is a frame too early: the places written down are the ones from before
460/// the room changed. Twenty frames is a third of a second at sixty, which is
461/// longer than a screen takes to build and shorter than a reader waits before
462/// deciding nothing happened.
463const REVEAL_TRIES: u8 = 20;
464
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago465/// How many frames a list that has just mounted keeps asking for the place it
466/// opens at.
467///
468/// `REVEAL_TRIES`' reasoning at the other end of the same problem. iced runs a
469/// widget operation against the tree the last `view` built, so the ask that
470/// goes out on the commit a scroll area ARRIVES on can find no such widget to
471/// act on — the scrollable it names is one this frame is only now building.
472/// Whether it lands is then a question of which happens first, which is not a
473/// question a room should open on: most of the time the conversation opened at
474/// its newest line and sometimes it opened at the top of the backlog.
475///
476/// Four frames rather than `reveal`'s twenty, and it stops the moment the list
477/// reports that it arrived. A mount settles in one or two; what is left of the
478/// budget after that is time spent fighting a reader who opened a room and
479/// immediately scrolled, and there is no reason to spend more of it than the
480/// mount actually needs.
481const SETTLE_TRIES: u8 = 4;
482
483/// Whether the list called `name` is already where a mount sent it — its end
484/// for `None`, that offset for `Some`.
485///
486/// Read off the memo, which is to say off what the toolkit last reported, so
487/// a list nobody has heard from yet has not arrived and is asked again.
488fn settled(memo: Option<&ScrollMemo>, want: Option<f32>) -> bool {
489 match (memo, want) {
490 (None, _) => false,
491 (Some(m), None) => m.at_end,
492 (Some(m), Some(y)) => (m.offset_y - y).abs() <= AT_END_SLACK,
493 }
494}
495
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago496/// The row of the scroll area called `name` that is asking to be shown, as the
497/// tree has it now.
498///
499/// Asked again on every attempt rather than carried, because a row is not the
500/// same node for long. A buffer that takes a line while a jump is landing is
501/// rebuilt under the reconciler, and the row that was node 412 a frame ago is
502/// node 587 now — so a retry holding the old number would look up a place for
503/// a row nobody has, and go on failing until it gave up. Which room a reader
504/// jumped into decided whether it worked, and that is exactly as strange as
505/// it sounds until you see what it depends on.
506fn asking_row(t: &Tree, name: &str) -> Option<i32> {
507 let mut found = None;
508 walk(t, t.root_id(), &mut |id, n| {
509 if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name {
510 return;
511 }
512 for row in &n.children {
513 let mut asked = false;
514 walk(t, *row, &mut |_, node| {
515 asked |= node.bool("scroll-here") == Some(true);
516 });
517 if asked {
518 found = Some(*row);
519 break;
520 }
521 }
522 });
523 found
524}
525
526/// Ask to be taken to the row of `name` that wants showing — now if its place
527/// is known, and on the next frame if it is not.
528///
529/// A task that is already finished is not a wasted frame: iced takes its
530/// message on the next pass of the loop, which is after this frame has been
531/// laid out — and being laid out is exactly what the row has to have done for
532/// there to be an answer.
533fn reveal(name: String, row: i32, viewport: f32) -> Task<Message> {
534 match placements(&name).get(row) {
535 Some((top, height)) => {
536 let y = centred_offset(top, height, viewport);
537 if scroll_log() {
538 eprintln!(
539 "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}"
540 );
541 }
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago542 iced_scrollable::scroll_to(
543 scroll_id(&name),
544 AbsoluteOffset {
545 x: None,
546 y: Some(y),
547 },
548 )
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago549 }
550 None => {
551 if scroll_log() {
552 eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again");
553 }
554 Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) })
555 }
556 }
557}
558
559/// How many rows the scroll area called `name` has, and how many of them are
560/// asking to be shown. For the log alone.
561fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) {
562 let mut shape = (0, 0);
563 walk(t, t.root_id(), &mut |id, n| {
564 if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name {
565 return;
566 }
567 shape.0 = n.children.len();
568 for row in &n.children {
569 let mut asked = false;
570 walk(t, *row, &mut |_, node| {
571 asked |= node.bool("scroll-here") == Some(true);
572 });
573 if asked {
574 shape.1 += 1;
575 }
576 }
577 });
578 shape
579}
580
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago581fn snap_to_end(name: &str) -> Task<Message> {
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago582 iced_scrollable::snap_to(
583 scroll_id(name),
584 RelativeOffset {
585 x: None,
586 y: Some(1.0),
587 },
588 )
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago589}
590
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago591// --- the app -----------------------------------------------------------------
592
593struct App {
594 core: Core,
595 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago596 scrolls: HashMap<String, ScrollMemo>,
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago597 /// What the window last wrote back into a control, by node and prop, with
598 /// the sequence number of the event that carried it to the worker.
599 typed: HashMap<(i32, &'static str), (u64, Prop)>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago600}
601
602#[derive(Clone, Debug)]
603enum Message {
604 Tree,
605 Quit,
606 Click(i32),
607 Toggled(i32, bool),
608 Change(i32, String),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago609 Paste(i32, String),
610 PastedPicture(i32, Option<Vec<u8>>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago611 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago612 Hover(i32),
613 Unhover(i32),
614 Scrolled(i32, String, Viewport),
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago615 /// Show whichever row of this scroll area is asking to be shown, and how
616 /// many more frames to keep trying for. See `reveal`.
617 Reveal(String, u8),
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago618 /// Put this scroll area where it opened at — its end for `None`, that
619 /// offset for `Some` — again, and how many more frames to keep at it.
620 /// See `SETTLE_TRIES`.
621 Settle(String, Option<f32>, u8),
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago622 PickImage,
623 Picked(Option<PathBuf>),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago624}
625
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago626/// Lay what was typed over a commit that has not caught up with it.
627///
628/// libcosmic paints a control from the tree, so a commit rendered before the
629/// worker saw the latest keystroke would put the older text back under the
630/// caret, and the next key would land on that. An entry is let go once a
631/// commit was rendered after its event: from then on the component's own
632/// state is the answer, a draft it cleared included.
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago633fn keep_typed(
634 tree: &mut Arc<Tree>,
635 typed: &mut HashMap<(i32, &'static str), (u64, Prop)>,
636 settled: u64,
637) {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago638 typed.retain(|&(node, key), (seq, value)| {
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago639 let Some(n) = tree.get(node) else {
640 return false;
641 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago642 if *seq <= settled {
643 return false;
644 }
645 if n.props.get(key) != Some(value) {
646 Arc::make_mut(tree).set(node, key, value.clone());
647 }
648 true
649 });
650}
651
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago652impl App {
653 /// A widget does not own its value: the new state goes into the arena and
654 /// 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 ago655 /// working control, and its next render is what settles it. Then the event
656 /// goes to the worker, and what was written is held over any commit
657 /// rendered before the worker saw it.
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago658 fn write_back(
659 &mut self,
660 node: i32,
661 key: &'static str,
662 value: Prop,
663 event: &'static str,
664 text: String,
665 num: f64,
666 ) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago667 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 ago668 Arc::make_mut(&mut self.tree).set(node, key, value.clone());
669 let seq = post_seq(node, event, text, num);
670 self.typed.insert((node, key), (seq, value));
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago671 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago672
673 /// Take the committed tree, and move every scroll area to where it should
674 /// be now that it has changed.
675 ///
676 /// A snap is relative, so a list snapped to its end stays at its end as
677 /// rows arrive under it, until the reader scrolls away.
678 fn take_tree(&mut self) -> Task<Message> {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago679 let (committed, settled) = {
680 let c = lock(&COMMITTED);
681 (c.clone(), COMMITTED_SETTLED.load(SeqCst))
682 };
683 let before = std::mem::replace(&mut self.tree, committed);
684 keep_typed(&mut self.tree, &mut self.typed, settled);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago685 let mut tasks = Vec::new();
686 let mut live = HashSet::new();
687 for ask in scroll_asks(&before, &self.tree) {
688 live.insert(ask.name.clone());
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago689 let memo = self.scrolls.entry(ask.name.clone()).or_insert(ScrollMemo {
690 at_end: ask.stick,
691 told: None,
692 offset_y: 0.0,
693 height: 0.0,
694 });
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago695 // A row asking to be shown is measured on the frame it appears,
696 // so the ask stands until the layout has a place for it — see
697 // `reveal`, which asks again rather than giving up.
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago698 let moved = scroll_move(&ask, memo);
699 match moved {
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago700 ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)),
701 ScrollMove::End => tasks.push(snap_to_end(&ask.name)),
702 ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to(
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago703 scroll_id(&ask.name),
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago704 AbsoluteOffset {
705 x: None,
706 y: Some(y),
707 },
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago708 )),
709 ScrollMove::Stay => {}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago710 }
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago711 // And keeps asking, while this is the commit the list arrived on:
712 // there may be no widget to have heard the ask above. `reveal`
713 // does its own asking again; the other two are asked for here.
714 if ask.fresh {
715 let want = match moved {
716 ScrollMove::End => Some(None),
717 ScrollMove::Restore(y) => Some(Some(y)),
718 _ => None,
719 };
720 if let Some(want) = want {
721 let name = ask.name.clone();
722 tasks.push(Task::future(async move {
723 cosmic::Action::App(Message::Settle(name, want, SETTLE_TRIES))
724 }));
725 }
726 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago727 }
728 // A list that was never scrolled keeps no memo worth the space; one
729 // that was keeps its place for when it comes back.
730 self.scrolls
731 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
732 Task::batch(tasks)
733 }
734
735 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
736 let y = viewport.absolute_offset().y;
737 let room = viewport.content_bounds().height - viewport.bounds().height;
738 let at_end = room - y <= AT_END_SLACK;
739 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
740 at_end,
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago741 told: None,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago742 offset_y: y,
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago743 height: viewport.bounds().height,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago744 });
745 memo.at_end = at_end;
746 memo.offset_y = y;
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago747 memo.height = viewport.bounds().height;
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago748 if let Some(place) = report(memo, at_end) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago749 post(node, "change", place.to_owned(), 0.0);
750 }
751 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago752}
753
754impl cosmic::Application for App {
755 type Executor = cosmic::executor::Default;
756 type Flags = String;
757 type Message = Message;
758 const APP_ID: &'static str = "dev.jolt.Glimmer";
759
760 fn core(&self) -> &Core {
761 &self.core
762 }
763
764 fn core_mut(&mut self) -> &mut Core {
765 &mut self.core
766 }
767
768 fn init(core: Core, title: String) -> (Self, Task<Message>) {
769 let mut app = App {
770 core,
771 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago772 scrolls: HashMap::new(),
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago773 typed: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago774 };
775 // libcosmic's `wayland` feature brings `multi-window` with it, which
776 // makes a window title a per-window thing.
777 app.set_header_title(title.clone());
778 let task = match app.core.main_window_id() {
779 Some(id) => app.set_window_title(title, id),
780 None => Task::none(),
781 };
782 (app, task)
783 }
784
785 fn subscription(&self) -> Subscription<Message> {
786 Subscription::run(wakes)
787 }
788
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago789 fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) {
790 WINDOW_W.store(width.max(0.0) as u32, SeqCst);
791 WINDOW_H.store(height.max(0.0) as u32, SeqCst);
792 }
793
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago794 fn update(&mut self, message: Message) -> Task<Message> {
795 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago796 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago797 Message::Quit => return cosmic::iced::exit(),
798 Message::Click(node) => post(node, "click", String::new(), 0.0),
799 Message::Toggled(node, on) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago800 let num = f64::from(u8::from(on));
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago801 self.write_back(
802 node,
803 "active",
804 Prop::Bool(on),
805 "toggled",
806 String::new(),
807 num,
808 );
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago809 }
810 Message::Change(node, text) => {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago811 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
812 }
813 // libcosmic's field answers Ctrl+V with the clipboard's text, and a
814 // clipboard holding a picture has none, so the field comes back as
815 // it was. That is the paste worth reporting: the picture is read
816 // here, where the clipboard is, and `paste-empty` goes to the
817 // worker, which collects it with `cosmic_clipboard_image_png`.
818 Message::Paste(node, text) => {
819 if self.tree.get(node).is_some_and(|n| n.str("text") == text) {
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago820 return cosmic::iced::clipboard::read_data::<ClipboardPng>().map(move |png| {
821 cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0)))
822 });
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago823 }
824 self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0);
825 }
826 Message::PastedPicture(node, png) => {
827 *lock(&CLIPBOARD_PNG) = png;
828 post(node, "paste-empty", String::new(), 0.0);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago829 }
830 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago831 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
832 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
833 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 ago834 // The row was not laid out when the jump was asked for. Look
835 // again, and keep looking for a few frames: a room the reader has
836 // only just been taken to has to be built before its lines have
837 // anywhere to be.
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago838 // Asked again until the list says it arrived, or the budget is
839 // out. Idempotent either way: both asks name an absolute place,
840 // so one that already landed lands on the same place again.
841 Message::Settle(name, want, tries) => {
842 if settled(self.scrolls.get(&name), want) || tries == 0 {
843 return Task::none();
844 }
845 let again = {
846 let name = name.clone();
847 Task::future(async move {
848 cosmic::Action::App(Message::Settle(name, want, tries - 1))
849 })
850 };
851 let now = match want {
852 None => snap_to_end(&name),
853 Some(y) => iced_scrollable::scroll_to(
854 scroll_id(&name),
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago855 AbsoluteOffset {
856 x: None,
857 y: Some(y),
858 },
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago859 ),
860 };
861 return Task::batch([now, again]);
862 }
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago863 Message::Reveal(name, tries) => {
864 let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height);
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago865 let place =
866 asking_row(&self.tree, &name).and_then(|row| placements(&name).get(row));
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago867 if let Some((top, height)) = place {
Leave the end flag to the thing that can see the end e39a374 nandi 7d ago868 // Nothing written down here either, for the reason the
869 // commit path gives: where this ends up is `scrolled`'s to
870 // 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 ago871 let y = centred_offset(top, height, viewport);
872 return iced_scrollable::scroll_to(
873 scroll_id(&name),
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago874 AbsoluteOffset {
875 x: None,
876 y: Some(y),
877 },
Keep asking until the row is there, and say so when it is not b49f82a nandi 7d ago878 );
879 }
880 if scroll_log() {
881 let asking = asking_row(&self.tree, &name);
882 let (rows, here) = scroll_shape(&self.tree, &name);
883 eprintln!(
884 "jolt-scroll: {name} retry {tries}, asking {asking:?}, \
885 {rows} rows, {here} asking to be shown, \
886 {} placed, viewport {viewport}",
887 placements(&name).len()
888 );
889 }
890 // Not landed yet. Keep trying for the whole budget rather
891 // than stopping the moment nothing is asking: a room the
892 // reader has just been taken to is built over several frames,
893 // and one where the rows are not in the tree yet looks exactly
894 // like a jump that is over. It is not over, it is early.
895 if tries > 0 {
896 return Task::future(async move {
897 cosmic::Action::App(Message::Reveal(name, tries - 1))
898 });
899 }
900 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago901 // The desktop's own chooser, through the portal, on libcosmic's
902 // executor: it is a D-Bus round trip, and the window keeps
903 // painting while it is open.
904 Message::PickImage => {
905 return Task::perform(
906 async {
907 rfd::AsyncFileDialog::new()
908 .set_title("Choose a picture")
909 .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"])
910 .pick_file()
911 .await
912 .map(|file| file.path().to_path_buf())
913 },
914 |path| cosmic::Action::App(Message::Picked(path)),
915 );
916 }
917 Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago918 }
919 Task::none()
920 }
921
922 fn view(&self) -> Element<'_, Message> {
923 let tree = &*self.tree;
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago924 let root = element(tree, tree.root_id(), true, false);
925 // A dialog that asked not to be modal, put up here rather than handed
926 // to `dialog` below. It is the same widget in the same place — a
927 // `popover` centres it exactly as `cosmic::app` does — and the whole
928 // of the difference is that this one is not told to intercept the
929 // pointer. That matters to anything the pointer opened: a modal
930 // popover hands the window underneath it a cursor that is
931 // `Unavailable`, so a face that opened a dialog on hover never hears
932 // the pointer leave, and what it opened can never close itself.
933 //
934 // The popover is here whether or not there is anything in it, which
935 // `cosmic::app` says of its own in one line and which this learned
936 // the long way: iced keeps a widget's state by where it sits in the
937 // tree, so a wrapper that comes and goes rebuilds everything under
938 // it — and what "everything" holds is the scroll positions. Wrapping
939 // only when a dialog appeared meant resting the pointer on a face
940 // jumped the conversation behind it.
941 let mut popover = widget::popover(root);
942 if let Some(id) = find_dialog(tree, false) {
943 // The dialog reports its own pointer, on the same two events a
944 // face or a pill reports theirs. Without it a dialog the pointer
945 // opened can only be read at arm's length: the client is told the
946 // pointer left what opened it and never told it arrived here, so
947 // the one way to keep it up is not to move — and everything in it
948 // is out of reach.
949 let popup = widget::mouse_area(dialog_of(tree, id))
950 .on_enter(Message::Hover(id))
951 .on_exit(Message::Unhover(id));
952 popover = popover.popup(popup);
953 }
954 popover.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago955 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago956
957 /// The MODAL dialog the tree is carrying, if it is carrying one.
958 ///
959 /// A client says there is one by putting a `dialog` node in the tree and
960 /// says there is not by leaving it out — the same way it says anything
961 /// else. What comes back is libcosmic's own dialog: centred, over a
962 /// dimmed window, and closed by the buttons the client hung on it.
963 ///
964 /// A dialog that says `modal false` does not come back here. This hook is
965 /// the modal one whether the client wants it or not — `cosmic::app` wraps
966 /// whatever it returns in `popover(..).modal(true)` — and `view` puts
967 /// that kind up itself. See `dialog_of`.
968 fn dialog(&self) -> Option<Element<'_, Message>> {
969 let tree = &*self.tree;
970 let id = find_dialog(tree, true)?;
971 Some(dialog_of(tree, id))
972 }
973}
974
975/// The first `dialog` node in the tree whose modality is `modal`.
976///
977/// Absent, `modal` is true: a dialog is the modal kind unless it says it is
978/// not, which is the shape everything else here takes — a prop left out is
979/// the ordinary answer.
980fn find_dialog(t: &Tree, modal: bool) -> Option<i32> {
981 let mut found = None;
982 walk(t, t.root_id(), &mut |id, n| {
983 if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal {
984 found = Some(id);
985 }
986 });
987 found
988}
989
990/// One `dialog` node as libcosmic's dialog.
991///
992/// `label` is its heading and `body` the line under it. Children are its
993/// controls, in order, except that a child carrying `slot` "primary" or
994/// "secondary" becomes that action instead — which is where libcosmic puts
995/// the buttons, at the foot and to the right.
996fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> {
997 let Some(n) = t.get(id) else {
998 return widget::Space::new().width(0).height(0).into();
999 };
1000 let mut d = widget::dialog();
1001 if !n.label().is_empty() {
1002 d = d.title(n.label().to_owned());
1003 }
1004 if !n.str("body").is_empty() {
1005 d = d.body(n.str("body").to_owned());
1006 }
1007 if let Some(w) = n.num("max-width") {
1008 d = d.max_width(w as f32);
1009 }
1010 for child in &n.children {
1011 let Some(c) = t.get(*child) else { continue };
1012 let el = element(t, *child, true, false);
1013 d = match c.str("slot") {
1014 "primary" => d.primary_action(el),
1015 "secondary" => d.secondary_action(el),
1016 _ => d.control(el),
1017 };
1018 }
1019 d.into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1020}
1021
1022// --- props into layout -----------------------------------------------------------
1023
1024/// `margin` all round, with `margin-top` and its siblings overriding a side.
1025fn margins(n: &Node) -> Padding {
1026 let all = n.num("margin").unwrap_or(0.0) as f32;
1027 let side = |key| n.num(key).map_or(all, |v| v as f32);
1028 Padding {
1029 top: side("margin-top"),
1030 right: side("margin-right"),
1031 bottom: side("margin-bottom"),
1032 left: side("margin-left"),
1033 }
1034}
1035
1036/// A width the client asked for. Zero is the client saying "none": frq writes
1037/// `:width-request 0` on its message column whenever the people panel is shut,
1038/// and taken literally that is a backlog laid out zero points wide.
1039fn width_request(n: &Node) -> Option<f32> {
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago1040 n.num("width-request")
1041 .filter(|w| *w > 0.0)
1042 .map(|w| w as f32)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1043}
1044
Put a row's `:align :end` where the row ends 6316bb1 nandi 6d ago1045/// `align`, or `default` where it is not set. A column starts its children at
1046/// the left. Rows do not ask: `align` on a row is where along the row its
1047/// children sit, not how they line up across it, and the row branch of
1048/// `element` reads it itself.
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1049fn alignment(n: &Node, default: Alignment) -> Alignment {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1050 match n.str("align") {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1051 "start" => Alignment::Start,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1052 "center" => Alignment::Center,
1053 "end" => Alignment::End,
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1054 _ => default,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1055 }
1056}
1057
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1058fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
1059 cosmic::theme::Container::custom(move |_| ContainerStyle {
1060 background: Some(Background::Color(color)),
1061 border: Border {
1062 radius: radius.into(),
1063 ..Border::default()
1064 },
1065 text_color: Some(Color::WHITE),
1066 ..ContainerStyle::default()
1067 })
1068}
1069
1070/// A colour for somebody, from their name, so the same person is the same
1071/// colour everywhere they appear.
1072fn name_colour(name: &str) -> Color {
1073 const PALETTE: [(f32, f32, f32); 8] = [
1074 (0.83, 0.33, 0.33),
1075 (0.85, 0.55, 0.20),
1076 (0.62, 0.62, 0.18),
1077 (0.30, 0.65, 0.35),
1078 (0.20, 0.62, 0.62),
1079 (0.30, 0.50, 0.85),
1080 (0.55, 0.40, 0.85),
1081 (0.80, 0.35, 0.65),
1082 ];
1083 let hash = name
1084 .bytes()
1085 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
1086 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
1087 Color::from_rgb(r, g, b)
1088}
1089
1090/// A picture that answers a click, with the pointer saying so.
1091fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
1092 if !enabled {
1093 return el;
1094 }
1095 widget::mouse_area(el)
1096 .on_press(Message::Click(id))
1097 .interaction(cosmic::iced::mouse::Interaction::Pointer)
1098 .into()
1099}
1100
1101fn picture(path: &str) -> Option<widget::image::Handle> {
1102 (!path.is_empty() && std::path::Path::new(path).exists())
1103 .then(|| widget::image::Handle::from_path(path))
1104}
1105
1106// --- the tree into widgets ---------------------------------------------------------
1107
1108/// One node and everything under it, as widgets.
1109///
1110/// `enabled` is inherited: an insensitive container takes its whole subtree out
1111/// of interaction. `in_row` is whether the parent lays its children out across:
1112/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
1113/// column in a column takes the width and a column in a row does not take the
1114/// row's slack unless it says `fill-height`.
1115fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1116 let Some(n) = t.get(id) else {
1117 return Column::new().into();
1118 };
1119 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1120 let fill_height = n.bool("fill-height") == Some(true);
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1121 // glimmer-jvui's theme spacing, where the client does not say: a list of
1122 // cards with nothing between them reads as one slab.
1123 let spacing = n.num("spacing").unwrap_or(6.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1124 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 ago1125
1126 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1127 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1128 .width(Length::Fill)
1129 .height(Length::Fill)
1130 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1131 // Sizes are set only where something asked for one. iced's rows and
1132 // columns take `Fill` on an axis from any child that fills it, which is
1133 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
1134 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
1135 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1136 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1137 let across = n.str("orientation") == "horizontal";
1138 if across {
Put a row's `:align :end` where the row ends 6316bb1 nandi 6d ago1139 // `align` on a row is the MAIN axis, which is glimmer's
1140 // meaning and the one the shared screens are written against:
1141 // `:end` lays the children out *from* the right, so the first
1142 // child in the source is the rightmost on screen. Read as a
1143 // cross-axis gravity instead it did nothing visible but sit
1144 // the chips low, and the message heading's pair came out in
1145 // the order ✏️ ↩️ 🙂 hard against the clock rather than the
1146 // other way round against the edge — see `chat/action-chips`.
1147 let from_end = n.str("align") == "end";
1148 let mut row = if from_end {
1149 Row::with_children(children(true).collect::<Vec<_>>().into_iter().rev())
1150 } else {
1151 Row::with_children(children(true))
1152 }
1153 .spacing(spacing)
1154 .padding(margins(n))
1155 // Across the row the children still centre: a chip beside a
1156 // label sitting against the top of it is what that is for.
1157 .align_y(Alignment::Center);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1158 // A row fills the width it is in only when it or something in
1159 // it asks to; otherwise a line of buttons would spread out.
1160 match width_request(n) {
1161 Some(w) => row = row.width(w),
1162 None if fill_height => row = row.width(Length::Fill),
1163 None => {}
1164 }
1165 if fill_height {
1166 row = row.height(Length::Fill);
Put a row's `:align :end` where the row ends 6316bb1 nandi 6d ago1167 }
1168 if from_end {
1169 // iced has no main-axis alignment on a Row, so the edge is
1170 // a container's doing: it takes the width and puts the row
1171 // against the right of it. Not a leading Fill space, which
1172 // would have halved the slack with a row that already has
1173 // something filling in it — the join box beside its button
1174 // is that row, and the box is meant to take all of it.
1175 return widget::container(row)
1176 .width(Length::Fill)
1177 .align_x(Horizontal::Right)
1178 .into();
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1179 }
1180 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1181 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1182 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1183 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1184 .padding(margins(n))
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1185 .align_x(alignment(n, Alignment::Start));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1186 match width_request(n) {
1187 Some(w) => column = column.width(w),
1188 None if fill_height || !in_row => column = column.width(Length::Fill),
1189 None => {}
1190 }
1191 if fill_height {
1192 column = column.height(Length::Fill);
1193 }
1194 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1195 }
1196 }
1197 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1198 let column = Column::with_children(children(false))
1199 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1200 .padding(24)
1201 .width(Length::Fill);
1202 let mut inner = widget::container(column).width(Length::Fill);
1203 if let Some(max) = n.num("max-width") {
1204 inner = inner.max_width(max as f32);
1205 }
1206 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1207 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1208 .height(Length::Fill)
1209 .into()
1210 }
1211 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1212 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 ago1213 if n.tag == "frame" && !n.label().is_empty() {
1214 column = column.push(widget::text::heading(n.label()));
1215 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1216 let card = widget::container(column.extend(children(false)))
1217 .padding(12)
1218 .class(cosmic::theme::Container::Card);
1219 match width_request(n) {
1220 Some(w) => card.width(w).into(),
1221 None if !in_row => card.width(Length::Fill).into(),
1222 None => card.into(),
1223 }
1224 }
1225 // Always fills both ways: a viewport that only fills its width asks its
1226 // column for no height, and is given none. The content is held to its
1227 // own height, since iced will not scroll content that fills the axis it
1228 // scrolls along.
1229 "scroll" => {
1230 let name = scroll_name(n, id);
1231 let content = Column::with_children(children(false))
1232 .spacing(spacing)
1233 .width(Length::Fill)
1234 .height(Length::Shrink);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1235 // Wrapped in the thing that writes down where each row landed, so
1236 // that "take me to this line" has an answer in points — which is
1237 // the only thing a scroll area can be told. See `rows`.
1238 let content = rows::Rows::new(content, n.children.clone(), placements(&name));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1239 widget::scrollable(content)
1240 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1241 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1242 .height(Length::Fill)
1243 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1244 .into()
1245 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1246 // Word wrapping that falls back to breaking inside a word: a URL is one
1247 // word, and it otherwise runs straight past the edge of its column.
1248 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label())
1249 .wrapping(Wrapping::WordOrGlyph)
1250 .into(),
1251 "label" => widget::text::body(n.label())
1252 .wrapping(Wrapping::WordOrGlyph)
1253 .into(),
1254 "title" => widget::text::title3(n.label())
1255 .wrapping(Wrapping::WordOrGlyph)
1256 .into(),
1257 "title-2" => widget::text::title4(n.label())
1258 .wrapping(Wrapping::WordOrGlyph)
1259 .into(),
1260 "dim-label" => widget::text::caption(n.label())
1261 .wrapping(Wrapping::WordOrGlyph)
1262 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1263 "button" => {
1264 let button = match n.str("kind") {
1265 "primary" => widget::button::suggested(n.label()),
1266 "destructive" => widget::button::destructive(n.label()),
1267 _ => widget::button::standard(n.label()),
1268 };
1269 button
1270 .on_press_maybe(enabled.then_some(Message::Click(id)))
1271 .into()
1272 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1273 "link" => widget::button::link(n.label().to_owned())
1274 .on_press_maybe(enabled.then_some(Message::Click(id)))
1275 .into(),
1276 // A dot that says whether the thing is live, and the words beside it.
1277 "status" => {
1278 let colour = if n.bool("live") == Some(true) {
1279 Color::from_rgb(0.30, 0.72, 0.40)
1280 } else {
1281 Color::from_rgb(0.55, 0.55, 0.55)
1282 };
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago1283 let dot = widget::container(widget::Space::new().width(8).height(8))
1284 .class(filled(colour, 4.0));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1285 Row::new()
1286 .spacing(6)
1287 .align_y(Alignment::Center)
1288 .push(dot)
1289 .push(widget::text::caption(n.label()))
1290 .into()
1291 }
1292 "spinner" => {
1293 let mut row = Row::new()
1294 .spacing(8)
1295 .align_y(Alignment::Center)
1296 .push(widget::progress_bar::indeterminate_circular().size(16.0));
1297 if !n.label().is_empty() {
1298 row = row.push(widget::text::caption(n.label()));
1299 }
1300 row.into()
1301 }
1302 "emoji" => {
1303 let glyph = match n.str("emoji") {
1304 "" => n.label(),
1305 e => e,
1306 };
1307 widget::text(glyph.to_owned())
1308 .size(n.num("size").unwrap_or(16.0) as f32)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1309 .font(EMOJI_FONT)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1310 .into()
1311 }
1312 // A round picture, or the initial on a colour from the name: most
1313 // 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 ago1314 //
1315 // And the three things a face is for besides being looked at. It
1316 // painted as a picture and nothing else until now: a client that
1317 // asked a face to answer a click, to report the pointer arriving, or
1318 // to carry a card under it was handed a portrait that did none of
1319 // them — so the profile behind every avatar in the window was
1320 // unreachable, and the hover card written for it never appeared.
1321 // Those are the same three things `reaction` below does, so they are
1322 // done the same way: `mouse_area` for the press and the two edges of
1323 // the hover, and a `tooltip` for whatever was hung underneath.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1324 "avatar" => {
1325 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 ago1326 let face: Element<'_, Message> = match picture(n.str("src")) {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1327 Some(handle) => widget::image(handle)
1328 .width(size)
1329 .height(size)
1330 .content_fit(ContentFit::Cover)
1331 .border_radius(size / 2.0)
1332 .into(),
1333 None => {
1334 let initial: String = n
1335 .label()
1336 .trim_start_matches(|c: char| !c.is_alphanumeric())
1337 .chars()
1338 .next()
1339 .map(|c| c.to_uppercase().collect())
1340 .unwrap_or_default();
1341 widget::container(widget::text(initial).size(size * 0.45))
1342 .center(Length::Fixed(size))
1343 .class(filled(name_colour(n.label()), size / 2.0))
1344 .into()
1345 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1346 };
1347 // The hover is reported whether or not the face is enabled: it
1348 // says where the pointer is, which is true of an insensitive
1349 // picture too. The press is not — an insensitive subtree is out
1350 // of interaction, which is what `enabled` means here.
1351 let mut area = widget::mouse_area(face)
1352 .on_enter(Message::Hover(id))
1353 .on_exit(Message::Unhover(id));
1354 if enabled {
1355 area = area
1356 .on_press(Message::Click(id))
1357 .interaction(cosmic::iced::mouse::Interaction::Pointer);
1358 }
1359 if n.children.is_empty() {
1360 area.into()
1361 } else {
1362 widget::tooltip(
1363 area,
1364 Column::with_children(children(false)).spacing(4),
1365 widget::tooltip::Position::Bottom,
1366 )
1367 .into()
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1368 }
1369 }
1370 // A pill: an emoji, how many people, and whether you are one of them.
1371 // What the client hangs under it is its hover card, shown while the
1372 // pointer is on the pill.
1373 "reaction" => {
1374 let glyph = match n.str("emoji") {
1375 "" => n.label(),
1376 e => e,
1377 };
1378 let size = n.num("size").unwrap_or(16.0) as f32;
1379 let mut content = Row::new()
1380 .spacing(4)
1381 .align_y(Alignment::Center)
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1382 .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1383 let count = n.num("count").unwrap_or(0.0);
1384 if count > 0.0 {
1385 content = content.push(widget::text::caption(format!("{count}")));
1386 }
1387 let class = if n.bool("mine") == Some(true) {
1388 widget::button::ButtonClass::Suggested
1389 } else {
1390 widget::button::ButtonClass::Standard
1391 };
1392 let pill = widget::button::custom(content)
1393 .padding([2, 8])
1394 .class(class)
1395 .on_press_maybe(enabled.then_some(Message::Click(id)));
1396 let pill = widget::mouse_area(pill)
1397 .on_enter(Message::Hover(id))
1398 .on_exit(Message::Unhover(id));
1399 if n.children.is_empty() {
1400 pill.into()
1401 } else {
1402 widget::tooltip(
1403 pill,
1404 Column::with_children(children(false)).spacing(4),
1405 widget::tooltip::Position::Bottom,
1406 )
1407 .into()
1408 }
1409 }
1410 // One tag for both kinds of picture, as in libvidya. `feed` is live
1411 // pixels pushed under a name, which nothing pushes here yet, so it
1412 // holds the slot the layout gave it.
1413 "image" => {
1414 let max_w = n.num("max-width").map(|v| v as f32);
1415 let max_h = n.num("max-height").map(|v| v as f32);
1416 if !n.str("feed").is_empty() {
1417 let w = max_w.unwrap_or(160.0);
1418 let h = max_h.unwrap_or(w * 0.75);
1419 widget::container(widget::text::caption("video"))
1420 .center_x(Length::Fixed(w))
1421 .center_y(Length::Fixed(h))
1422 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
1423 .into()
1424 } else if let Some(handle) = picture(n.str("src")) {
1425 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
1426 if n.bool("fit") == Some(true) {
1427 image = image.width(Length::Fill).height(Length::Fill);
1428 } else if let Some(size) = n.num("size") {
1429 image = image.width(size as f32).height(size as f32);
1430 }
1431 let mut bounded = widget::container(image);
1432 if let Some(w) = max_w {
1433 bounded = bounded.max_width(w);
1434 }
1435 if let Some(h) = max_h {
1436 bounded = bounded.max_height(h);
1437 }
1438 clickable(bounded.into(), id, enabled)
1439 } else {
1440 widget::Space::new().width(0).height(0).into()
1441 }
1442 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1443 "checkbutton" => {
1444 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
1445 if enabled {
1446 check = check.on_toggle(move |on| Message::Toggled(id, on));
1447 }
1448 check.into()
1449 }
1450 "entry" => {
1451 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
1452 if enabled {
1453 entry = entry
1454 .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 ago1455 .on_paste(move |text| Message::Paste(id, text))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1456 .on_submit(move |_| Message::Activate(id));
1457 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1458 let width = match width_request(n) {
1459 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
1460 _ => Length::Fill,
1461 };
1462 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1463 }
1464 "separator" => widget::divider::horizontal::default().into(),
1465 "spacer" => {
1466 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1467 if n.str("expand").is_empty() {
1468 widget::Space::new().width(size).height(size).into()
1469 } else {
1470 widget::Space::new().width(Length::Fill).height(size).into()
1471 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1472 }
1473 "progress" => {
1474 let bar =
1475 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
1476 if n.label().is_empty() {
1477 bar.into()
1478 } else {
1479 Column::new()
1480 .spacing(4)
1481 .push(widget::text::caption(n.label()))
1482 .push(bar)
1483 .into()
1484 }
1485 }
Answer the pointer on a face, and carry a dialog in the tree 228672d nandi 7d ago1486 // The one node that is not painted where it stands. libcosmic puts a
1487 // dialog up itself, centred over the window and dimming what is
1488 // behind it — `Application::dialog` is the hook, and it is asked for
1489 // one separately from `view`. So the tree carries the dialog wherever
1490 // the client found it convenient to write it, `App::dialog` goes and
1491 // finds it there, and this leaves nothing behind in the layout. A
1492 // node rendered in both places would be painted twice.
1493 "dialog" => widget::Space::new().width(0).height(0).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1494 // Kept rather than refused, as in libvidya: a tag this backend has not
1495 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1496 _ => Column::with_children(children(false))
1497 .spacing(spacing)
1498 .padding(margins(n))
1499 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1500 };
1501
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1502 // The containers and the entry size themselves above; anything else asked
1503 // for a width gets it from a wrapper.
1504 match (n.tag.as_str(), width_request(n)) {
1505 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
1506 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1507 }
1508}
1509
1510// --- the C ABI: the loop -------------------------------------------------------
1511
1512static TITLE: Mutex<String> = Mutex::new(String::new());
1513
1514/// The window's title, read when `cosmic_run` opens it. A call of its own
1515/// because jolt will not pass a string to a `:blocking` foreign procedure, and
1516/// `cosmic_run` has to be one.
1517///
1518/// # Safety
1519/// `title` is null or a NUL-terminated string.
1520#[no_mangle]
1521pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
1522 let title = borrowed(title);
1523 guard((), || *lock(&TITLE) = title)
1524}
1525
1526/// Open the window and run libcosmic until it closes. Blocks; call it on the
1527/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
1528///
1529/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
1530/// in this process — winit's event loop cannot be made twice.
1531#[no_mangle]
1532pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
1533 let status = guard(1, || {
1534 let title = lock(&TITLE).clone();
1535 if RAN.swap(true, SeqCst) {
1536 log::error!("jolt-cosmic: a window already ran in this process");
1537 return 2;
1538 }
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1539 // The size asked for, until libcosmic reports the one it got.
1540 WINDOW_W.store(width.max(1) as u32, SeqCst);
1541 WINDOW_H.store(height.max(1) as u32, SeqCst);
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1542 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
1543 let mut settings = cosmic::app::Settings::default().size(size);
1544 match mode {
1545 1 => settings = settings.theme(cosmic::Theme::dark()),
1546 2 => settings = settings.theme(cosmic::Theme::light()),
1547 _ => {}
1548 }
1549 match cosmic::app::run::<App>(settings, title) {
1550 Ok(()) => 0,
1551 Err(err) => {
1552 eprintln!("jolt-cosmic: {err}");
1553 1
1554 }
1555 }
1556 });
1557 // Outside the guard, so a panic in libcosmic still releases the worker.
1558 *lock(&TO_APP) = None;
1559 CLOSED.store(true, SeqCst);
1560 BELL.notify_all();
1561 status
1562}
1563
1564/// 1 once `cosmic_run` has returned.
1565#[no_mangle]
1566pub extern "C" fn cosmic_should_close() -> c_int {
1567 c_int::from(CLOSED.load(SeqCst))
1568}
1569
1570/// Close the window. Asked before the window exists, it closes on opening.
1571#[no_mangle]
1572pub extern "C" fn cosmic_quit() {
1573 guard((), || {
1574 QUIT_ASKED.store(true, SeqCst);
1575 tell_app(Wake::Quit);
1576 })
1577}
1578
1579/// Publish the edits since the last commit. Answers 1 when there were any.
1580#[no_mangle]
1581pub extern "C" fn cosmic_tree_commit() -> c_int {
1582 guard(0, || {
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1583 let settled = lock(&INBOX).settled;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1584 let snapshot = {
1585 let mut e = lock(&EDITS);
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1586 // A pass that only settled events still publishes, so a control
1587 // holding typed text over an older commit lets go of it.
1588 if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1589 return 0;
1590 }
1591 e.dirty = false;
1592 Arc::new(e.tree.clone())
1593 };
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1594 {
1595 let mut committed = lock(&COMMITTED);
1596 *committed = snapshot;
1597 COMMITTED_SETTLED.store(settled, SeqCst);
1598 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1599 tell_app(Wake::Tree);
1600 1
1601 })
1602}
1603
1604/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
1605/// closing. Answers 1 when an event is waiting.
1606#[no_mangle]
1607pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
1608 guard(0, || {
1609 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
1610 let (mut inbox, _) = BELL
1611 .wait_timeout_while(lock(&INBOX), timeout, |i| {
1612 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
1613 })
1614 .unwrap_or_else(|poisoned| poisoned.into_inner());
1615 inbox.woken = false;
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1616 inbox.settled = inbox.taken;
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1617 c_int::from(!inbox.queue.is_empty())
1618 })
1619}
1620
1621/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
1622#[no_mangle]
1623pub extern "C" fn cosmic_wake() {
1624 guard((), || {
1625 lock(&INBOX).woken = true;
1626 BELL.notify_all();
1627 })
1628}
1629
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1630// --- the C ABI: the window and the desktop -----------------------------------------
1631
1632/// The window's width in points; the size asked for until it has opened.
1633#[no_mangle]
1634pub extern "C" fn cosmic_window_width() -> c_int {
1635 WINDOW_W.load(SeqCst) as c_int
1636}
1637
1638#[no_mangle]
1639pub extern "C" fn cosmic_window_height() -> c_int {
1640 WINDOW_H.load(SeqCst) as c_int
1641}
1642
1643/// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when
1644/// there is no window to ask from; the choice arrives through
1645/// `cosmic_picked_image`.
1646#[no_mangle]
1647pub extern "C" fn cosmic_pick_image() -> c_int {
1648 guard(0, || {
1649 if lock(&TO_APP).is_none() {
1650 return 0;
1651 }
1652 *lock(&PICK) = Pick::Open;
1653 tell_app(Wake::PickImage);
1654 1
1655 })
1656}
1657
1658/// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture
1659/// was chosen since the last call; 0 while the chooser is open, after it was
1660/// cancelled, or when the picture could not be read.
1661///
1662/// # Safety
1663/// `path` is null or a NUL-terminated string.
1664#[no_mangle]
1665pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int {
1666 let path = borrowed(path);
1667 guard(0, || {
1668 let chosen = {
1669 let mut pick = lock(&PICK);
1670 match std::mem::replace(&mut *pick, Pick::Idle) {
1671 Pick::Chosen(chosen) => chosen,
1672 other => {
1673 *pick = other;
1674 return 0;
1675 }
1676 }
1677 };
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago1678 match image::open(&chosen)
1679 .and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png))
1680 {
Report the window size, open the portal chooser, and colour emoji 71cdc87 nandi 8d ago1681 Ok(()) => 1,
1682 Err(err) => {
1683 eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display());
1684 0
1685 }
1686 }
1687 })
1688}
1689
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1690/// Write the picture the last empty Ctrl+V found on the clipboard to `path`.
1691/// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was
1692/// already taken, or when the file could not be written.
1693///
1694/// # Safety
1695/// `path` is null or a NUL-terminated string.
1696#[no_mangle]
1697pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int {
1698 let path = borrowed(path);
1699 guard(0, || {
1700 let Some(png) = lock(&CLIPBOARD_PNG).take() else {
1701 return 0;
1702 };
1703 match std::fs::write(&*path, png) {
1704 Ok(()) => 1,
1705 Err(err) => {
1706 eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}");
1707 0
1708 }
1709 }
1710 })
1711}
1712
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1713// --- the C ABI: events -----------------------------------------------------------
1714
1715static EVENT_NAME: Scratch = Scratch::new();
1716static EVENT_TEXT: Scratch = Scratch::new();
1717
1718/// Dequeue one event; 1 while there was one. The accessors describe it.
1719#[no_mangle]
1720pub extern "C" fn cosmic_tree_poll_event() -> c_int {
1721 guard(0, || {
1722 let mut inbox = lock(&INBOX);
1723 let next = inbox.queue.pop_front();
1724 let got = next.is_some();
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1725 if let Some(e) = &next {
1726 inbox.taken = e.seq;
1727 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1728 inbox.current = next;
1729 c_int::from(got)
1730 })
1731}
1732
1733#[no_mangle]
1734pub extern "C" fn cosmic_tree_event_node() -> c_int {
1735 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
1736}
1737
1738#[no_mangle]
1739pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
1740 guard(empty_str(), || {
1741 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
1742 })
1743}
1744
1745#[no_mangle]
1746pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
1747 guard(empty_str(), || {
1748 let text = lock(&INBOX)
1749 .current
1750 .as_ref()
1751 .map(|e| e.text.clone())
1752 .unwrap_or_default();
1753 EVENT_TEXT.lend(text)
1754 })
1755}
1756
1757#[no_mangle]
1758pub extern "C" fn cosmic_tree_event_num() -> f64 {
1759 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
1760}
1761
1762// --- the C ABI: nodes --------------------------------------------------------------
1763
1764static PROPS: Scratch = Scratch::new();
1765static DUMP: Scratch = Scratch::new();
1766
1767#[no_mangle]
1768pub extern "C" fn cosmic_tree_root() -> c_int {
1769 guard(0, || edit(Tree::root))
1770}
1771
1772/// # Safety
1773/// `tag` is null or a NUL-terminated string.
1774#[no_mangle]
1775pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
1776 let tag = borrowed(tag);
1777 guard(0, || edit(|t| t.new_node(&tag)))
1778}
1779
1780#[no_mangle]
1781pub extern "C" fn cosmic_node_free(node: c_int) {
1782 guard((), || edit(|t| t.free(node)))
1783}
1784
1785#[no_mangle]
1786pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
1787 guard(0, || c_int::from(read(|t| t.exists(node))))
1788}
1789
1790/// # Safety
1791/// `key` and `value` are null or NUL-terminated strings.
1792#[no_mangle]
1793pub unsafe extern "C" fn cosmic_node_set_str(
1794 node: c_int,
1795 key: *const c_char,
1796 value: *const c_char,
1797) {
1798 let (key, value) = (borrowed(key), borrowed(value));
1799 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
1800}
1801
1802/// # Safety
1803/// `key` is null or a NUL-terminated string.
1804#[no_mangle]
1805pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
1806 let key = borrowed(key);
1807 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
1808}
1809
1810/// # Safety
1811/// `key` is null or a NUL-terminated string.
1812#[no_mangle]
1813pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
1814 let key = borrowed(key);
1815 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1816}
1817
1818#[no_mangle]
1819pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1820 guard((), || edit(|t| t.clear_props(node)))
1821}
1822
1823#[no_mangle]
1824pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1825 guard(empty_str(), || {
1826 PROPS.lend(read(|t| {
1827 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1828 }))
1829 })
1830}
1831
1832#[no_mangle]
1833pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1834 guard(0, || {
1835 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1836 })
1837}
1838
1839#[no_mangle]
1840pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1841 guard(0, || {
1842 read(|t| {
1843 t.get(node)
1844 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1845 .unwrap_or(0)
1846 })
1847 })
1848}
1849
1850#[no_mangle]
1851pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1852 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1853}
1854
1855/// Unparents AND frees `child` with everything under it.
1856#[no_mangle]
1857pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1858 guard((), || edit(|t| t.remove(parent, child)))
1859}
1860
1861#[no_mangle]
1862pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1863 guard(0, || {
1864 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1865 })
1866}
1867
1868#[no_mangle]
1869pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1870 guard(0, || {
1871 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1872 })
1873}
1874
1875/// The subtree at `node` as hiccup; 0 is the root.
1876#[no_mangle]
1877pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1878 guard(empty_str(), || {
1879 DUMP.lend(read(|t| {
1880 let id = if node == 0 { t.root_id() } else { node };
1881 t.dump(id)
1882 }))
1883 })
1884}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1885
1886#[cfg(test)]
1887mod tests {
1888 use super::*;
1889
1890 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1891 let id = t.new_node(tag);
1892 assert!(t.append(parent, id));
1893 id
1894 }
1895
Hold typed text over a stale commit, and paste a picture into an entry 1ee075a nandi 8d ago1896 #[test]
1897 fn typed_text_stands_over_a_commit_that_has_not_seen_it() {
1898 let mut t = Tree::default();
1899 let root = t.root();
1900 let entry = node(&mut t, root, "entry");
1901 t.set(entry, "text", Prop::Str("a".into()));
1902 let mut tree = Arc::new(t);
1903 let mut typed = HashMap::new();
1904 typed.insert((entry, "text"), (2, Prop::Str("ab".into())));
1905
1906 // Rendered before the worker saw the "b".
1907 keep_typed(&mut tree, &mut typed, 1);
1908 assert_eq!(tree.get(entry).unwrap().str("text"), "ab");
1909 assert_eq!(typed.len(), 1);
1910
1911 // Rendered after: the component cleared its draft, and that stands.
1912 Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new()));
1913 keep_typed(&mut tree, &mut typed, 2);
1914 assert_eq!(tree.get(entry).unwrap().str("text"), "");
1915 assert!(typed.is_empty());
1916 }
1917
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1918 #[test]
1919 fn a_zero_width_request_is_no_request() {
1920 let mut t = Tree::default();
1921 let root = t.root();
1922 let column = node(&mut t, root, "vbox");
1923 t.set(column, "width-request", Prop::Num(0.0));
1924 assert_eq!(width_request(t.get(column).unwrap()), None);
1925 t.set(column, "width-request", Prop::Num(260.0));
1926 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1927 }
1928
1929 #[test]
1930 fn a_scroll_is_named_by_its_scroll_key() {
1931 let mut t = Tree::default();
1932 let root = t.root();
1933 let list = node(&mut t, root, "scroll");
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago1934 assert_eq!(
1935 scroll_name(t.get(list).unwrap(), list),
1936 format!("node-{list}")
1937 );
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1938 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1939 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1940 }
1941
1942 #[test]
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1943 fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() {
1944 // A row halfway down a long backlog, in a 600pt viewport: half the
1945 // viewport above it, less half the row.
1946 assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0);
1947 // The same row with no viewport reported yet: its own top.
1948 assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0);
1949 // A row near the top cannot be centred without scrolling above the
1950 // content, and nothing is above the content.
1951 assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0);
1952 // A row taller than the viewport is shown from its own top: there is
1953 // no middle of it to put in the middle.
1954 assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0);
1955 }
1956
1957 #[test]
1958 fn scroll_here_asks_with_its_row_and_goes_on_asking() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1959 let mut t = Tree::default();
1960 let root = t.root();
1961 let list = node(&mut t, root, "scroll");
1962 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1963 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1964 let before = t.clone();
1965 t.set(rows[3], "scroll-here", Prop::Bool(true));
1966
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1967 // 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 ago1968 let asks = scroll_asks(&before, &t);
1969 assert_eq!(asks.len(), 1);
1970 assert!(!asks[0].fresh);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1971 assert_eq!(asks[0].reveal, Some(rows[3]));
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1972
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1973 // And it goes on asking while the row is still asking: the row may not
1974 // have been laid out on the commit the ask arrived.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1975 let again = scroll_asks(&t, &t);
Let a list say where it put its rows, and jump there 0ab003e nandi 7d ago1976 assert_eq!(again[0].reveal, Some(rows[3]));
1977
1978 // A node asking from deeper inside a row answers with the row.
1979 t.set(rows[3], "scroll-here", Prop::Bool(false));
1980 let inner = node(&mut t, rows[1], "vbox");
1981 t.set(inner, "scroll-here", Prop::Bool(true));
1982 assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1]));
1983
1984 // Nothing asking, nothing to reveal.
1985 t.set(inner, "scroll-here", Prop::Bool(false));
1986 assert_eq!(scroll_asks(&t, &t)[0].reveal, None);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1987 }
1988
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago1989 /// An ask for the scroll area called `name`, with nothing going on.
1990 fn ask(name: &str) -> ScrollAsk {
1991 ScrollAsk {
1992 name: name.to_owned(),
1993 stick: true,
1994 tick: Some(0.0),
1995 tick_before: Some(0.0),
1996 fresh: false,
1997 reveal: None,
1998 }
1999 }
2000
2001 fn memo() -> ScrollMemo {
What the formatter has been asking for since the scroll memo landed 12754c2 nandi 16h ago2002 ScrollMemo {
2003 at_end: true,
2004 told: Some(true),
2005 offset_y: 0.0,
2006 height: 600.0,
2007 }
Tell the client where a scroll area landed, not where it changed 4f920af nandi 6d ago2008 }
2009
2010 #[test]
2011 fn a_jump_asks_for_the_end_without_saying_it_arrived() {
2012 let mut m = memo();
2013 m.at_end = false;
2014 m.told = Some(false);
2015 let mut a = ask("backlog");
2016 a.tick = Some(1.0);
2017 assert_eq!(scroll_move(&a, &mut m), ScrollMove::End);
2018 // The memo still says what the last report said, so the report that
2019 // comes back from the toolkit is a change, and is passed on. A memo
2020 // that marked itself here would swallow it, and the client would go
2021 // on believing the reader was away from the newest line.
2022 assert!(!m.at_end);
2023 assert_eq!(report(&mut m, true), Some("end"));
2024 assert_eq!(report(&mut m, true), None);
2025 }
2026
2027 #[test]
2028 fn a_list_that_has_just_mounted_says_where_it_is() {
2029 // The reader left this list away from the end, and the client was
2030 // told so. It comes back — another room under the same widget, or the
2031 // same room after the lightbox took the screen — and lands at the end
2032 // because it sticks there. Nothing about `at_end` CHANGED across
2033 // that, and the client still has to hear it: what it believes is
2034 // about the list this one replaced.
2035 let mut m = memo();
2036 m.at_end = true;
2037 m.told = Some(false);
2038 let mut a = ask("backlog");
2039 a.fresh = true;
2040 assert_eq!(scroll_move(&a, &mut m), ScrollMove::End);
2041 assert_eq!(report(&mut m, true), Some("end"));
2042 }
2043
2044 #[test]
2045 fn a_list_nobody_moved_is_not_reported_twice() {
2046 let mut m = memo();
2047 assert_eq!(report(&mut m, true), None);
2048 assert_eq!(report(&mut m, false), Some("away"));
2049 assert_eq!(report(&mut m, false), None);
2050 assert_eq!(report(&mut m, true), Some("end"));
2051 }
2052
2053 #[test]
2054 fn a_row_asking_to_be_shown_beats_the_end() {
2055 let mut m = memo();
2056 let mut a = ask("backlog");
2057 a.reveal = Some(42);
2058 a.tick = Some(1.0);
2059 assert_eq!(scroll_move(&a, &mut m), ScrollMove::Reveal(42));
2060 }
2061
2062 #[test]
2063 fn a_list_coming_back_is_put_where_it_was_left() {
2064 let mut m = memo();
2065 m.at_end = false;
2066 m.offset_y = 512.0;
2067 let mut a = ask("backlog");
2068 a.fresh = true;
2069 assert_eq!(scroll_move(&a, &mut m), ScrollMove::Restore(512.0));
2070 // And it is asked about again, since the client's belief is about
2071 // whatever was under this name before.
2072 assert_eq!(m.told, None);
2073 assert_eq!(report(&mut m, false), Some("away"));
2074 }
2075
Keep asking for the place a list opens at 8e8cd51 nandi 6d ago2076 #[test]
2077 fn a_mount_keeps_asking_until_the_list_says_it_arrived() {
2078 // Nothing heard from yet: the ask above may have found no widget to
2079 // act on, so it stands.
2080 assert!(!settled(None, None));
2081 assert!(!settled(None, Some(512.0)));
2082
2083 // Reported somewhere else: still asking.
2084 let mut m = memo();
2085 m.at_end = false;
2086 m.offset_y = 0.0;
2087 assert!(!settled(Some(&m), None));
2088 assert!(!settled(Some(&m), Some(512.0)));
2089
2090 // Arrived, and the asking stops.
2091 m.at_end = true;
2092 assert!(settled(Some(&m), None));
2093 m.offset_y = 512.0;
2094 assert!(settled(Some(&m), Some(512.0)));
2095 // Within the slack a fractional offset leaves.
2096 m.offset_y = 511.0;
2097 assert!(settled(Some(&m), Some(512.0)));
2098 m.offset_y = 480.0;
2099 assert!(!settled(Some(&m), Some(512.0)));
2100 }
2101
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago2102 #[test]
2103 fn a_jump_is_the_counter_moving() {
2104 let mut t = Tree::default();
2105 let root = t.root();
2106 let list = node(&mut t, root, "scroll");
2107 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
2108 let before = t.clone();
2109 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
2110 let asks = scroll_asks(&before, &t);
2111 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
2112 }
2113}