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