nandi/jolt-nativepublic Fork 0
7703c757d45b22224a0423d7b0dc4699a8ca2f9e
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 · 1131 lines · 39.4 KBRust Blame HistoryRaw
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago1//! glimmer's libcosmic backend: the retained-tree ABI, with iced reading it.
2//!
3//! The edit half is libvidya's — integer node handles, string-keyed props,
4//! events queued and polled — so `glimmer-cosmic` is `glimmer-vidya` pointed
5//! at a different object. What changes is who owns the loop.
6//!
7//! egui lets its caller drive frames; iced does not. `cosmic::app::run` takes
8//! the main thread (winit insists) and returns when the window closes. So the
9//! arrangement is inverted:
10//!
11//! * `cosmic_run` blocks the process main thread inside libcosmic.
12//! * jolt reconciles on a worker thread, mutating the arena under a mutex.
13//! Nothing it does is visible until `cosmic_tree_commit`, which snapshots the
14//! tree and wakes iced — so a reconcile half-way through a patch is never
15//! painted, and a commit with no edits behind it costs nothing.
16//! * Interactions are queued, and `cosmic_wait` blocks the worker until there
17//! is one (or `cosmic_wake`, or a timeout), so an idle window burns no CPU on
18//! either side.
19//!
20//! Every call except `cosmic_run` may come from any thread.
21
22mod tree;
23
24pub use tree::{Node, Prop, Tree};
25
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago26use std::collections::{HashMap, HashSet, VecDeque};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago27use std::ffi::{c_char, c_int};
28use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
29use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
30use std::time::Duration;
31
32use cosmic::app::{Core, Task};
33use cosmic::iced::futures::channel::mpsc;
34use cosmic::iced::futures::{Stream, StreamExt};
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago35use cosmic::iced::widget::container::Style as ContainerStyle;
36use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
37use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Length, Padding, Subscription};
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago38use cosmic::widget::{self, Column, Row};
39use cosmic::{ApplicationExt, Element};
40use jolt_abi::{borrowed, empty_str, guard, Scratch};
41
42fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
43 m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
44}
45
46// --- the arena ---------------------------------------------------------------
47
48struct Edits {
49 tree: Tree,
50 /// Set by every mutation, cleared by a commit that published it.
51 dirty: bool,
52}
53
54static EDITS: LazyLock<Mutex<Edits>> = LazyLock::new(|| {
55 Mutex::new(Edits {
56 tree: Tree::default(),
57 dirty: false,
58 })
59});
60
61/// What `view` paints: the tree as of the last commit.
62static COMMITTED: LazyLock<Mutex<Arc<Tree>>> = LazyLock::new(Default::default);
63
64fn edit<R>(f: impl FnOnce(&mut Tree) -> R) -> R {
65 let mut e = lock(&EDITS);
66 e.dirty = true;
67 f(&mut e.tree)
68}
69
70fn read<R>(f: impl FnOnce(&Tree) -> R) -> R {
71 f(&lock(&EDITS).tree)
72}
73
74// --- events, towards jolt ----------------------------------------------------
75
76struct Event {
77 node: i32,
78 name: &'static str,
79 text: String,
80 num: f64,
81}
82
83struct Inbox {
84 queue: VecDeque<Event>,
85 current: Option<Event>,
86 woken: bool,
87}
88
89static INBOX: Mutex<Inbox> = Mutex::new(Inbox {
90 queue: VecDeque::new(),
91 current: None,
92 woken: false,
93});
94static BELL: Condvar = Condvar::new();
95
96fn post(node: i32, name: &'static str, text: String, num: f64) {
97 lock(&INBOX).queue.push_back(Event {
98 node,
99 name,
100 text,
101 num,
102 });
103 BELL.notify_all();
104}
105
106// --- wakes, towards iced -----------------------------------------------------
107
108enum Wake {
109 Tree,
110 Quit,
111}
112
113static TO_APP: Mutex<Option<mpsc::UnboundedSender<Wake>>> = Mutex::new(None);
114static QUIT_ASKED: AtomicBool = AtomicBool::new(false);
115static RAN: AtomicBool = AtomicBool::new(false);
116static CLOSED: AtomicBool = AtomicBool::new(false);
117
118fn tell_app(wake: Wake) {
119 if let Some(tx) = lock(&TO_APP).as_ref() {
120 let _ = tx.unbounded_send(wake);
121 }
122}
123
124/// The subscription's stream. It opens with a `Tree` wake so a commit made
125/// between `init` and the subscription starting is not missed, and repeats a
126/// quit asked for before there was anyone to tell.
127fn wakes() -> impl Stream<Item = Message> {
128 let (tx, rx) = mpsc::unbounded();
129 let _ = tx.unbounded_send(Wake::Tree);
130 if QUIT_ASKED.load(SeqCst) {
131 let _ = tx.unbounded_send(Wake::Quit);
132 }
133 *lock(&TO_APP) = Some(tx);
134 rx.map(|wake| match wake {
135 Wake::Tree => Message::Tree,
136 Wake::Quit => Message::Quit,
137 })
138}
139
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago140// --- scroll areas ------------------------------------------------------------
141
142/// Where a scroll area was left, kept by name rather than on the widget.
143///
144/// iced keeps a scrollable's offset in its widget tree, and a widget that is
145/// unmounted and mounted again starts at the top. glimmer clients unmount
146/// lists all the time — frq's lightbox is a screen, so looking at a picture
147/// takes the backlog away — so the place is remembered here, under the
148/// `scroll-key` the client names the list by, and put back when it returns.
149struct ScrollMemo {
150 /// Whether the reader is at the newest line. A `stick-to-bottom` list
151 /// follows what arrives only while this holds.
152 at_end: bool,
153 offset_y: f32,
154}
155
156/// Two points of slack: a viewport scrolled to its end by a fractional
157/// offset is still at the end.
158const AT_END_SLACK: f32 = 2.0;
159
160fn scroll_name(n: &Node, id: i32) -> String {
161 match n.str("scroll-key") {
162 "" => format!("node-{id}"),
163 key => key.to_owned(),
164 }
165}
166
167fn scroll_id(name: &str) -> widget::Id {
168 widget::Id::new(format!("jolt-scroll-{name}"))
169}
170
171fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
172 if let Some(n) = t.get(id) {
173 f(id, n);
174 for child in &n.children {
175 walk(t, *child, f);
176 }
177 }
178}
179
180/// What a commit asks of one scroll area.
181struct ScrollAsk {
182 name: String,
183 stick: bool,
184 /// The `scroll-to-bottom` counter, and what it was in the tree before.
185 tick: Option<f64>,
186 tick_before: Option<f64>,
187 /// Not in the tree before this commit: mounted, or mounted again.
188 fresh: bool,
189 /// Where, as a fraction of the list, a node that has just been asked to be
190 /// shown sits.
191 reveal: Option<f32>,
192}
193
194/// Every scroll area in `now`, and what changed about each since `before`.
195fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
196 let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
197 walk(before, before.root_id(), &mut |id, n| {
198 if n.tag == "scroll" {
199 named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
200 }
201 });
202
203 let mut asks = Vec::new();
204 walk(now, now.root_id(), &mut |id, n| {
205 if n.tag != "scroll" {
206 return;
207 }
208 let name = scroll_name(n, id);
209 // A row asking to be shown, that was not asking last commit. The
210 // position is the index of the top-level row holding it: exact for a
211 // list of rows the same height and close for frq's backlog, and there
212 // is no layout to ask from here.
213 let count = n.children.len();
214 let mut reveal = None;
215 for (index, row) in n.children.iter().enumerate() {
216 let mut asked = false;
217 walk(now, *row, &mut |nid, node| {
218 let here = node.bool("scroll-here") == Some(true);
219 let was = before
220 .get(nid)
221 .is_some_and(|old| old.bool("scroll-here") == Some(true));
222 asked |= here && !was;
223 });
224 if asked {
225 reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);
226 break;
227 }
228 }
229 asks.push(ScrollAsk {
230 fresh: !named_before.contains_key(&name),
231 tick_before: named_before.get(&name).copied().flatten(),
232 tick: n.num("scroll-to-bottom"),
233 stick: n.bool("stick-to-bottom") == Some(true),
234 reveal,
235 name,
236 });
237 });
238 asks
239}
240
241fn snap_to_end(name: &str) -> Task<Message> {
242 iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
243}
244
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago245// --- the app -----------------------------------------------------------------
246
247struct App {
248 core: Core,
249 tree: Arc<Tree>,
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago250 scrolls: HashMap<String, ScrollMemo>,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago251}
252
253#[derive(Clone, Debug)]
254enum Message {
255 Tree,
256 Quit,
257 Click(i32),
258 Toggled(i32, bool),
259 Change(i32, String),
260 Activate(i32),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago261 Hover(i32),
262 Unhover(i32),
263 Scrolled(i32, String, Viewport),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago264}
265
266impl App {
267 /// A widget does not own its value: the new state goes into the arena and
268 /// into what is painted, so a caller that ignores the event still sees a
269 /// working control, and its next render is what settles it.
270 fn write_back(&mut self, node: i32, key: &str, value: Prop) {
271 edit(|t| t.set(node, key, value.clone()));
272 Arc::make_mut(&mut self.tree).set(node, key, value);
273 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago274
275 /// Take the committed tree, and move every scroll area to where it should
276 /// be now that it has changed.
277 ///
278 /// A snap is relative, so a list snapped to its end stays at its end as
279 /// rows arrive under it, until the reader scrolls away.
280 fn take_tree(&mut self) -> Task<Message> {
281 let before = std::mem::replace(&mut self.tree, lock(&COMMITTED).clone());
282 let mut tasks = Vec::new();
283 let mut live = HashSet::new();
284 for ask in scroll_asks(&before, &self.tree) {
285 live.insert(ask.name.clone());
286 let memo = self
287 .scrolls
288 .entry(ask.name.clone())
289 .or_insert(ScrollMemo {
290 at_end: ask.stick,
291 offset_y: 0.0,
292 });
293 let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
294 if let Some(fraction) = ask.reveal {
295 memo.at_end = false;
296 tasks.push(iced_scrollable::snap_to(
297 scroll_id(&ask.name),
298 RelativeOffset { x: None, y: Some(fraction) },
299 ));
300 } else if jumped || (ask.stick && memo.at_end) {
301 memo.at_end = true;
302 tasks.push(snap_to_end(&ask.name));
303 } else if ask.fresh {
304 tasks.push(iced_scrollable::scroll_to(
305 scroll_id(&ask.name),
306 AbsoluteOffset { x: None, y: Some(memo.offset_y) },
307 ));
308 }
309 }
310 // A list that was never scrolled keeps no memo worth the space; one
311 // that was keeps its place for when it comes back.
312 self.scrolls
313 .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
314 Task::batch(tasks)
315 }
316
317 fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
318 let y = viewport.absolute_offset().y;
319 let room = viewport.content_bounds().height - viewport.bounds().height;
320 let at_end = room - y <= AT_END_SLACK;
321 let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
322 at_end,
323 offset_y: y,
324 });
325 let was = memo.at_end;
326 memo.at_end = at_end;
327 memo.offset_y = y;
328 // "end" or "away", the strings libvidya emits: frq's handler compares
329 // against "end".
330 if was != at_end {
331 let place = if at_end { "end" } else { "away" };
332 post(node, "change", place.to_owned(), 0.0);
333 }
334 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago335}
336
337impl cosmic::Application for App {
338 type Executor = cosmic::executor::Default;
339 type Flags = String;
340 type Message = Message;
341 const APP_ID: &'static str = "dev.jolt.Glimmer";
342
343 fn core(&self) -> &Core {
344 &self.core
345 }
346
347 fn core_mut(&mut self) -> &mut Core {
348 &mut self.core
349 }
350
351 fn init(core: Core, title: String) -> (Self, Task<Message>) {
352 let mut app = App {
353 core,
354 tree: lock(&COMMITTED).clone(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago355 scrolls: HashMap::new(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago356 };
357 // libcosmic's `wayland` feature brings `multi-window` with it, which
358 // makes a window title a per-window thing.
359 app.set_header_title(title.clone());
360 let task = match app.core.main_window_id() {
361 Some(id) => app.set_window_title(title, id),
362 None => Task::none(),
363 };
364 (app, task)
365 }
366
367 fn subscription(&self) -> Subscription<Message> {
368 Subscription::run(wakes)
369 }
370
371 fn update(&mut self, message: Message) -> Task<Message> {
372 match message {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago373 Message::Tree => return self.take_tree(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago374 Message::Quit => return cosmic::iced::exit(),
375 Message::Click(node) => post(node, "click", String::new(), 0.0),
376 Message::Toggled(node, on) => {
377 self.write_back(node, "active", Prop::Bool(on));
378 post(node, "toggled", String::new(), f64::from(u8::from(on)));
379 }
380 Message::Change(node, text) => {
381 self.write_back(node, "text", Prop::Str(text.clone()));
382 post(node, "change", text, 0.0);
383 }
384 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago385 Message::Hover(node) => post(node, "hover", String::new(), 0.0),
386 Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
387 Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago388 }
389 Task::none()
390 }
391
392 fn view(&self) -> Element<'_, Message> {
393 let tree = &*self.tree;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago394 element(tree, tree.root_id(), true, false)
395 }
396}
397
398// --- props into layout -----------------------------------------------------------
399
400/// `margin` all round, with `margin-top` and its siblings overriding a side.
401fn margins(n: &Node) -> Padding {
402 let all = n.num("margin").unwrap_or(0.0) as f32;
403 let side = |key| n.num(key).map_or(all, |v| v as f32);
404 Padding {
405 top: side("margin-top"),
406 right: side("margin-right"),
407 bottom: side("margin-bottom"),
408 left: side("margin-left"),
409 }
410}
411
412/// A width the client asked for. Zero is the client saying "none": frq writes
413/// `:width-request 0` on its message column whenever the people panel is shut,
414/// and taken literally that is a backlog laid out zero points wide.
415fn width_request(n: &Node) -> Option<f32> {
416 n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
417}
418
419fn alignment(n: &Node) -> Alignment {
420 match n.str("align") {
421 "center" => Alignment::Center,
422 "end" => Alignment::End,
423 _ => Alignment::Start,
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago424 }
425}
426
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago427fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
428 cosmic::theme::Container::custom(move |_| ContainerStyle {
429 background: Some(Background::Color(color)),
430 border: Border {
431 radius: radius.into(),
432 ..Border::default()
433 },
434 text_color: Some(Color::WHITE),
435 ..ContainerStyle::default()
436 })
437}
438
439/// A colour for somebody, from their name, so the same person is the same
440/// colour everywhere they appear.
441fn name_colour(name: &str) -> Color {
442 const PALETTE: [(f32, f32, f32); 8] = [
443 (0.83, 0.33, 0.33),
444 (0.85, 0.55, 0.20),
445 (0.62, 0.62, 0.18),
446 (0.30, 0.65, 0.35),
447 (0.20, 0.62, 0.62),
448 (0.30, 0.50, 0.85),
449 (0.55, 0.40, 0.85),
450 (0.80, 0.35, 0.65),
451 ];
452 let hash = name
453 .bytes()
454 .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
455 let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
456 Color::from_rgb(r, g, b)
457}
458
459/// A picture that answers a click, with the pointer saying so.
460fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
461 if !enabled {
462 return el;
463 }
464 widget::mouse_area(el)
465 .on_press(Message::Click(id))
466 .interaction(cosmic::iced::mouse::Interaction::Pointer)
467 .into()
468}
469
470fn picture(path: &str) -> Option<widget::image::Handle> {
471 (!path.is_empty() && std::path::Path::new(path).exists())
472 .then(|| widget::image::Handle::from_path(path))
473}
474
475// --- the tree into widgets ---------------------------------------------------------
476
477/// One node and everything under it, as widgets.
478///
479/// `enabled` is inherited: an insensitive container takes its whole subtree out
480/// of interaction. `in_row` is whether the parent lays its children out across:
481/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
482/// column in a column takes the width and a column in a row does not take the
483/// row's slack unless it says `fill-height`.
484fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago485 let Some(n) = t.get(id) else {
486 return Column::new().into();
487 };
488 let enabled = enabled && n.bool("sensitive") != Some(false);
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago489 let fill_height = n.bool("fill-height") == Some(true);
490 let spacing = n.num("spacing").unwrap_or(0.0) as f32;
491 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 ago492
493 let el: Element<'_, Message> = match n.tag.as_str() {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago494 "window" => Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago495 .width(Length::Fill)
496 .height(Length::Fill)
497 .into(),
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago498 // Sizes are set only where something asked for one. iced's rows and
499 // columns take `Fill` on an axis from any child that fills it, which is
500 // glimmer-jvui's `fills-height?` rule done for us — and an explicit
501 // `Shrink` would throw that away, so a wrapper with no `fill-height` of
502 // its own would hand the list inside it no height at all.
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago503 "box" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago504 let across = n.str("orientation") == "horizontal";
505 if across {
506 let mut row = Row::with_children(children(true))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago507 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago508 .padding(margins(n))
509 .align_y(alignment(n));
510 // A row fills the width it is in only when it or something in
511 // it asks to; otherwise a line of buttons would spread out.
512 match width_request(n) {
513 Some(w) => row = row.width(w),
514 None if fill_height => row = row.width(Length::Fill),
515 None => {}
516 }
517 if fill_height {
518 row = row.height(Length::Fill);
519 }
520 row.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago521 } else {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago522 let mut column = Column::with_children(children(false))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago523 .spacing(spacing)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago524 .padding(margins(n))
525 .align_x(alignment(n));
526 match width_request(n) {
527 Some(w) => column = column.width(w),
528 None if fill_height || !in_row => column = column.width(Length::Fill),
529 None => {}
530 }
531 if fill_height {
532 column = column.height(Length::Fill);
533 }
534 column.into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago535 }
536 }
537 "page" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago538 let column = Column::with_children(children(false))
539 .spacing(n.num("spacing").unwrap_or(8.0) as f32)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago540 .padding(24)
541 .width(Length::Fill);
542 let mut inner = widget::container(column).width(Length::Fill);
543 if let Some(max) = n.num("max-width") {
544 inner = inner.max_width(max as f32);
545 }
546 widget::scrollable(widget::container(inner).center_x(Length::Fill))
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago547 .width(Length::Fill)
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago548 .height(Length::Fill)
549 .into()
550 }
551 "card" | "frame" => {
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago552 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 ago553 if n.tag == "frame" && !n.label().is_empty() {
554 column = column.push(widget::text::heading(n.label()));
555 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago556 let card = widget::container(column.extend(children(false)))
557 .padding(12)
558 .class(cosmic::theme::Container::Card);
559 match width_request(n) {
560 Some(w) => card.width(w).into(),
561 None if !in_row => card.width(Length::Fill).into(),
562 None => card.into(),
563 }
564 }
565 // Always fills both ways: a viewport that only fills its width asks its
566 // column for no height, and is given none. The content is held to its
567 // own height, since iced will not scroll content that fills the axis it
568 // scrolls along.
569 "scroll" => {
570 let name = scroll_name(n, id);
571 let content = Column::with_children(children(false))
572 .spacing(spacing)
573 .width(Length::Fill)
574 .height(Length::Shrink);
575 widget::scrollable(content)
576 .id(scroll_id(&name))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago577 .width(Length::Fill)
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago578 .height(Length::Fill)
579 .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago580 .into()
581 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago582 "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago583 "label" => widget::text::body(n.label()).into(),
584 "title" => widget::text::title3(n.label()).into(),
585 "title-2" => widget::text::title4(n.label()).into(),
586 "dim-label" => widget::text::caption(n.label()).into(),
587 "button" => {
588 let button = match n.str("kind") {
589 "primary" => widget::button::suggested(n.label()),
590 "destructive" => widget::button::destructive(n.label()),
591 _ => widget::button::standard(n.label()),
592 };
593 button
594 .on_press_maybe(enabled.then_some(Message::Click(id)))
595 .into()
596 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago597 "link" => widget::button::link(n.label().to_owned())
598 .on_press_maybe(enabled.then_some(Message::Click(id)))
599 .into(),
600 // A dot that says whether the thing is live, and the words beside it.
601 "status" => {
602 let colour = if n.bool("live") == Some(true) {
603 Color::from_rgb(0.30, 0.72, 0.40)
604 } else {
605 Color::from_rgb(0.55, 0.55, 0.55)
606 };
607 let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
608 Row::new()
609 .spacing(6)
610 .align_y(Alignment::Center)
611 .push(dot)
612 .push(widget::text::caption(n.label()))
613 .into()
614 }
615 "spinner" => {
616 let mut row = Row::new()
617 .spacing(8)
618 .align_y(Alignment::Center)
619 .push(widget::progress_bar::indeterminate_circular().size(16.0));
620 if !n.label().is_empty() {
621 row = row.push(widget::text::caption(n.label()));
622 }
623 row.into()
624 }
625 "emoji" => {
626 let glyph = match n.str("emoji") {
627 "" => n.label(),
628 e => e,
629 };
630 widget::text(glyph.to_owned())
631 .size(n.num("size").unwrap_or(16.0) as f32)
632 .into()
633 }
634 // A round picture, or the initial on a colour from the name: most
635 // people in most rooms have no picture, so the initial IS the avatar.
636 "avatar" => {
637 let size = n.num("size").unwrap_or(32.0) as f32;
638 match picture(n.str("src")) {
639 Some(handle) => widget::image(handle)
640 .width(size)
641 .height(size)
642 .content_fit(ContentFit::Cover)
643 .border_radius(size / 2.0)
644 .into(),
645 None => {
646 let initial: String = n
647 .label()
648 .trim_start_matches(|c: char| !c.is_alphanumeric())
649 .chars()
650 .next()
651 .map(|c| c.to_uppercase().collect())
652 .unwrap_or_default();
653 widget::container(widget::text(initial).size(size * 0.45))
654 .center(Length::Fixed(size))
655 .class(filled(name_colour(n.label()), size / 2.0))
656 .into()
657 }
658 }
659 }
660 // A pill: an emoji, how many people, and whether you are one of them.
661 // What the client hangs under it is its hover card, shown while the
662 // pointer is on the pill.
663 "reaction" => {
664 let glyph = match n.str("emoji") {
665 "" => n.label(),
666 e => e,
667 };
668 let size = n.num("size").unwrap_or(16.0) as f32;
669 let mut content = Row::new()
670 .spacing(4)
671 .align_y(Alignment::Center)
672 .push(widget::text(glyph.to_owned()).size(size));
673 let count = n.num("count").unwrap_or(0.0);
674 if count > 0.0 {
675 content = content.push(widget::text::caption(format!("{count}")));
676 }
677 let class = if n.bool("mine") == Some(true) {
678 widget::button::ButtonClass::Suggested
679 } else {
680 widget::button::ButtonClass::Standard
681 };
682 let pill = widget::button::custom(content)
683 .padding([2, 8])
684 .class(class)
685 .on_press_maybe(enabled.then_some(Message::Click(id)));
686 let pill = widget::mouse_area(pill)
687 .on_enter(Message::Hover(id))
688 .on_exit(Message::Unhover(id));
689 if n.children.is_empty() {
690 pill.into()
691 } else {
692 widget::tooltip(
693 pill,
694 Column::with_children(children(false)).spacing(4),
695 widget::tooltip::Position::Bottom,
696 )
697 .into()
698 }
699 }
700 // One tag for both kinds of picture, as in libvidya. `feed` is live
701 // pixels pushed under a name, which nothing pushes here yet, so it
702 // holds the slot the layout gave it.
703 "image" => {
704 let max_w = n.num("max-width").map(|v| v as f32);
705 let max_h = n.num("max-height").map(|v| v as f32);
706 if !n.str("feed").is_empty() {
707 let w = max_w.unwrap_or(160.0);
708 let h = max_h.unwrap_or(w * 0.75);
709 widget::container(widget::text::caption("video"))
710 .center_x(Length::Fixed(w))
711 .center_y(Length::Fixed(h))
712 .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
713 .into()
714 } else if let Some(handle) = picture(n.str("src")) {
715 let mut image = widget::image(handle).content_fit(ContentFit::Contain);
716 if n.bool("fit") == Some(true) {
717 image = image.width(Length::Fill).height(Length::Fill);
718 } else if let Some(size) = n.num("size") {
719 image = image.width(size as f32).height(size as f32);
720 }
721 let mut bounded = widget::container(image);
722 if let Some(w) = max_w {
723 bounded = bounded.max_width(w);
724 }
725 if let Some(h) = max_h {
726 bounded = bounded.max_height(h);
727 }
728 clickable(bounded.into(), id, enabled)
729 } else {
730 widget::Space::new().width(0).height(0).into()
731 }
732 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago733 "checkbutton" => {
734 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
735 if enabled {
736 check = check.on_toggle(move |on| Message::Toggled(id, on));
737 }
738 check.into()
739 }
740 "entry" => {
741 let mut entry = widget::text_input(n.str("placeholder"), n.str("text"));
742 if enabled {
743 entry = entry
744 .on_input(move |text| Message::Change(id, text))
745 .on_submit(move |_| Message::Activate(id));
746 }
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago747 let width = match width_request(n) {
748 Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
749 _ => Length::Fill,
750 };
751 entry.width(width).into()
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago752 }
753 "separator" => widget::divider::horizontal::default().into(),
754 "spacer" => {
755 let size = n.num("size").unwrap_or(8.0) as f32;
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago756 if n.str("expand").is_empty() {
757 widget::Space::new().width(size).height(size).into()
758 } else {
759 widget::Space::new().width(Length::Fill).height(size).into()
760 }
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago761 }
762 "progress" => {
763 let bar =
764 widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32);
765 if n.label().is_empty() {
766 bar.into()
767 } else {
768 Column::new()
769 .spacing(4)
770 .push(widget::text::caption(n.label()))
771 .push(bar)
772 .into()
773 }
774 }
775 // Kept rather than refused, as in libvidya: a tag this backend has not
776 // grown yet still shows its children.
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago777 _ => Column::with_children(children(false))
778 .spacing(spacing)
779 .padding(margins(n))
780 .into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago781 };
782
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago783 // The containers and the entry size themselves above; anything else asked
784 // for a width gets it from a wrapper.
785 match (n.tag.as_str(), width_request(n)) {
786 ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
787 (_, Some(width)) => widget::container(el).width(width).into(),
glimmer-cosmic: a libcosmic backend for glimmer (spike) 6a3304d nandi 8d ago788 }
789}
790
791// --- the C ABI: the loop -------------------------------------------------------
792
793static TITLE: Mutex<String> = Mutex::new(String::new());
794
795/// The window's title, read when `cosmic_run` opens it. A call of its own
796/// because jolt will not pass a string to a `:blocking` foreign procedure, and
797/// `cosmic_run` has to be one.
798///
799/// # Safety
800/// `title` is null or a NUL-terminated string.
801#[no_mangle]
802pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) {
803 let title = borrowed(title);
804 guard((), || *lock(&TITLE) = title)
805}
806
807/// Open the window and run libcosmic until it closes. Blocks; call it on the
808/// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light.
809///
810/// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run
811/// in this process — winit's event loop cannot be made twice.
812#[no_mangle]
813pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int {
814 let status = guard(1, || {
815 let title = lock(&TITLE).clone();
816 if RAN.swap(true, SeqCst) {
817 log::error!("jolt-cosmic: a window already ran in this process");
818 return 2;
819 }
820 let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32);
821 let mut settings = cosmic::app::Settings::default().size(size);
822 match mode {
823 1 => settings = settings.theme(cosmic::Theme::dark()),
824 2 => settings = settings.theme(cosmic::Theme::light()),
825 _ => {}
826 }
827 match cosmic::app::run::<App>(settings, title) {
828 Ok(()) => 0,
829 Err(err) => {
830 eprintln!("jolt-cosmic: {err}");
831 1
832 }
833 }
834 });
835 // Outside the guard, so a panic in libcosmic still releases the worker.
836 *lock(&TO_APP) = None;
837 CLOSED.store(true, SeqCst);
838 BELL.notify_all();
839 status
840}
841
842/// 1 once `cosmic_run` has returned.
843#[no_mangle]
844pub extern "C" fn cosmic_should_close() -> c_int {
845 c_int::from(CLOSED.load(SeqCst))
846}
847
848/// Close the window. Asked before the window exists, it closes on opening.
849#[no_mangle]
850pub extern "C" fn cosmic_quit() {
851 guard((), || {
852 QUIT_ASKED.store(true, SeqCst);
853 tell_app(Wake::Quit);
854 })
855}
856
857/// Publish the edits since the last commit. Answers 1 when there were any.
858#[no_mangle]
859pub extern "C" fn cosmic_tree_commit() -> c_int {
860 guard(0, || {
861 let snapshot = {
862 let mut e = lock(&EDITS);
863 if !e.dirty {
864 return 0;
865 }
866 e.dirty = false;
867 Arc::new(e.tree.clone())
868 };
869 *lock(&COMMITTED) = snapshot;
870 tell_app(Wake::Tree);
871 1
872 })
873}
874
875/// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window
876/// closing. Answers 1 when an event is waiting.
877#[no_mangle]
878pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int {
879 guard(0, || {
880 let timeout = Duration::from_millis(timeout_ms.max(0) as u64);
881 let (mut inbox, _) = BELL
882 .wait_timeout_while(lock(&INBOX), timeout, |i| {
883 i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst)
884 })
885 .unwrap_or_else(|poisoned| poisoned.into_inner());
886 inbox.woken = false;
887 c_int::from(!inbox.queue.is_empty())
888 })
889}
890
891/// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere.
892#[no_mangle]
893pub extern "C" fn cosmic_wake() {
894 guard((), || {
895 lock(&INBOX).woken = true;
896 BELL.notify_all();
897 })
898}
899
900// --- the C ABI: events -----------------------------------------------------------
901
902static EVENT_NAME: Scratch = Scratch::new();
903static EVENT_TEXT: Scratch = Scratch::new();
904
905/// Dequeue one event; 1 while there was one. The accessors describe it.
906#[no_mangle]
907pub extern "C" fn cosmic_tree_poll_event() -> c_int {
908 guard(0, || {
909 let mut inbox = lock(&INBOX);
910 let next = inbox.queue.pop_front();
911 let got = next.is_some();
912 inbox.current = next;
913 c_int::from(got)
914 })
915}
916
917#[no_mangle]
918pub extern "C" fn cosmic_tree_event_node() -> c_int {
919 guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node))
920}
921
922#[no_mangle]
923pub extern "C" fn cosmic_tree_event_name() -> *const c_char {
924 guard(empty_str(), || {
925 EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name))
926 })
927}
928
929#[no_mangle]
930pub extern "C" fn cosmic_tree_event_text() -> *const c_char {
931 guard(empty_str(), || {
932 let text = lock(&INBOX)
933 .current
934 .as_ref()
935 .map(|e| e.text.clone())
936 .unwrap_or_default();
937 EVENT_TEXT.lend(text)
938 })
939}
940
941#[no_mangle]
942pub extern "C" fn cosmic_tree_event_num() -> f64 {
943 guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num))
944}
945
946// --- the C ABI: nodes --------------------------------------------------------------
947
948static PROPS: Scratch = Scratch::new();
949static DUMP: Scratch = Scratch::new();
950
951#[no_mangle]
952pub extern "C" fn cosmic_tree_root() -> c_int {
953 guard(0, || edit(Tree::root))
954}
955
956/// # Safety
957/// `tag` is null or a NUL-terminated string.
958#[no_mangle]
959pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int {
960 let tag = borrowed(tag);
961 guard(0, || edit(|t| t.new_node(&tag)))
962}
963
964#[no_mangle]
965pub extern "C" fn cosmic_node_free(node: c_int) {
966 guard((), || edit(|t| t.free(node)))
967}
968
969#[no_mangle]
970pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int {
971 guard(0, || c_int::from(read(|t| t.exists(node))))
972}
973
974/// # Safety
975/// `key` and `value` are null or NUL-terminated strings.
976#[no_mangle]
977pub unsafe extern "C" fn cosmic_node_set_str(
978 node: c_int,
979 key: *const c_char,
980 value: *const c_char,
981) {
982 let (key, value) = (borrowed(key), borrowed(value));
983 guard((), || edit(|t| t.set(node, &key, Prop::Str(value))))
984}
985
986/// # Safety
987/// `key` is null or a NUL-terminated string.
988#[no_mangle]
989pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) {
990 let key = borrowed(key);
991 guard((), || edit(|t| t.set(node, &key, Prop::Num(value))))
992}
993
994/// # Safety
995/// `key` is null or a NUL-terminated string.
996#[no_mangle]
997pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
998 let key = borrowed(key);
999 guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0))))
1000}
1001
1002#[no_mangle]
1003pub extern "C" fn cosmic_node_clear_props(node: c_int) {
1004 guard((), || edit(|t| t.clear_props(node)))
1005}
1006
1007#[no_mangle]
1008pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char {
1009 guard(empty_str(), || {
1010 PROPS.lend(read(|t| {
1011 t.get(node).map(|n| n.tag.clone()).unwrap_or_default()
1012 }))
1013 })
1014}
1015
1016#[no_mangle]
1017pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int {
1018 guard(0, || {
1019 read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int))
1020 })
1021}
1022
1023#[no_mangle]
1024pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int {
1025 guard(0, || {
1026 read(|t| {
1027 t.get(node)
1028 .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied())
1029 .unwrap_or(0)
1030 })
1031 })
1032}
1033
1034#[no_mangle]
1035pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int {
1036 guard(0, || c_int::from(edit(|t| t.append(parent, child))))
1037}
1038
1039/// Unparents AND frees `child` with everything under it.
1040#[no_mangle]
1041pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) {
1042 guard((), || edit(|t| t.remove(parent, child)))
1043}
1044
1045#[no_mangle]
1046pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
1047 guard(0, || {
1048 c_int::from(edit(|t| t.insert_after(parent, child, sibling)))
1049 })
1050}
1051
1052#[no_mangle]
1053pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
1054 guard(0, || {
1055 c_int::from(edit(|t| t.replace(parent, old_child, new_child)))
1056 })
1057}
1058
1059/// The subtree at `node` as hiccup; 0 is the root.
1060#[no_mangle]
1061pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
1062 guard(empty_str(), || {
1063 DUMP.lend(read(|t| {
1064 let id = if node == 0 { t.root_id() } else { node };
1065 t.dump(id)
1066 }))
1067 })
1068}
Paint the rest of frq's tags in glimmer-cosmic 7703c75 nandi 8d ago1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073
1074 fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1075 let id = t.new_node(tag);
1076 assert!(t.append(parent, id));
1077 id
1078 }
1079
1080 #[test]
1081 fn a_zero_width_request_is_no_request() {
1082 let mut t = Tree::default();
1083 let root = t.root();
1084 let column = node(&mut t, root, "vbox");
1085 t.set(column, "width-request", Prop::Num(0.0));
1086 assert_eq!(width_request(t.get(column).unwrap()), None);
1087 t.set(column, "width-request", Prop::Num(260.0));
1088 assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1089 }
1090
1091 #[test]
1092 fn a_scroll_is_named_by_its_scroll_key() {
1093 let mut t = Tree::default();
1094 let root = t.root();
1095 let list = node(&mut t, root, "scroll");
1096 assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1097 t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1098 assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1099 }
1100
1101 #[test]
1102 fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {
1103 let mut t = Tree::default();
1104 let root = t.root();
1105 let list = node(&mut t, root, "scroll");
1106 t.set(list, "scroll-key", Prop::Str("backlog".into()));
1107 let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1108 let before = t.clone();
1109 t.set(rows[3], "scroll-here", Prop::Bool(true));
1110
1111 let asks = scroll_asks(&before, &t);
1112 assert_eq!(asks.len(), 1);
1113 assert!(!asks[0].fresh);
1114 assert_eq!(asks[0].reveal, Some(0.75));
1115
1116 let again = scroll_asks(&t, &t);
1117 assert_eq!(again[0].reveal, None);
1118 }
1119
1120 #[test]
1121 fn a_jump_is_the_counter_moving() {
1122 let mut t = Tree::default();
1123 let root = t.root();
1124 let list = node(&mut t, root, "scroll");
1125 t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1126 let before = t.clone();
1127 t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1128 let asks = scroll_asks(&before, &t);
1129 assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1130 }
1131}