//! glimmer's libcosmic backend: the retained-tree ABI, with iced reading it. //! //! The edit half is libvidya's — integer node handles, string-keyed props, //! events queued and polled — so `glimmer-cosmic` is `glimmer-vidya` pointed //! at a different object. What changes is who owns the loop. //! //! egui lets its caller drive frames; iced does not. `cosmic::app::run` takes //! the main thread (winit insists) and returns when the window closes. So the //! arrangement is inverted: //! //! * `cosmic_run` blocks the process main thread inside libcosmic. //! * jolt reconciles on a worker thread, mutating the arena under a mutex. //! Nothing it does is visible until `cosmic_tree_commit`, which snapshots the //! tree and wakes iced — so a reconcile half-way through a patch is never //! painted, and a commit with no edits behind it costs nothing. //! * Interactions are queued, and `cosmic_wait` blocks the worker until there //! is one (or `cosmic_wake`, or a timeout), so an idle window burns no CPU on //! either side. //! //! Every call except `cosmic_run` may come from any thread. mod rows; mod tree; pub use tree::{Node, Prop, Tree}; use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::{c_char, c_int}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering::SeqCst}; use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; use std::time::Duration; use cosmic::app::{Core, Task}; use cosmic::iced::alignment::Horizontal; use cosmic::iced::futures::channel::mpsc; use cosmic::iced::futures::{Stream, StreamExt}; use cosmic::iced::widget::container::Style as ContainerStyle; use cosmic::iced::widget::scrollable::{ self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport, }; use cosmic::iced::widget::text::Wrapping; use cosmic::iced::{ Alignment, Background, Border, Color, ContentFit, Font, Length, Padding, Subscription, }; use cosmic::widget::{self, Column, Row}; use cosmic::{ApplicationExt, Element}; use jolt_abi::{borrowed, empty_str, guard, Scratch}; fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } // --- the arena --------------------------------------------------------------- struct Edits { tree: Tree, /// Set by every mutation, cleared by a commit that published it. dirty: bool, } static EDITS: LazyLock> = LazyLock::new(|| { Mutex::new(Edits { tree: Tree::default(), dirty: false, }) }); /// What `view` paints: the tree as of the last commit. static COMMITTED: LazyLock>> = LazyLock::new(Default::default); /// The inbox's `settled` when that commit was made. Written under /// `COMMITTED`'s lock, so the two are read as a pair. static COMMITTED_SETTLED: AtomicU64 = AtomicU64::new(0); fn edit(f: impl FnOnce(&mut Tree) -> R) -> R { let mut e = lock(&EDITS); e.dirty = true; f(&mut e.tree) } fn read(f: impl FnOnce(&Tree) -> R) -> R { f(&lock(&EDITS).tree) } // --- events, towards jolt ---------------------------------------------------- struct Event { seq: u64, node: i32, name: &'static str, text: String, num: f64, } struct Inbox { queue: VecDeque, current: Option, woken: bool, /// The sequence number of the last event posted. posted: u64, /// The sequence number of the last event the worker dequeued. taken: u64, /// `taken` as of the worker's last `cosmic_wait`. Every event up to here /// had its handler run on an earlier pass, so whatever it re-rendered is /// in the arena by the next commit. settled: u64, } static INBOX: Mutex = Mutex::new(Inbox { queue: VecDeque::new(), current: None, woken: false, posted: 0, taken: 0, settled: 0, }); static BELL: Condvar = Condvar::new(); fn post(node: i32, name: &'static str, text: String, num: f64) { post_seq(node, name, text, num); } /// Queue an event for the worker; answers its sequence number. fn post_seq(node: i32, name: &'static str, text: String, num: f64) -> u64 { let seq = { let mut inbox = lock(&INBOX); inbox.posted += 1; let seq = inbox.posted; inbox.queue.push_back(Event { seq, node, name, text, num, }); seq }; BELL.notify_all(); seq } // --- wakes, towards iced ----------------------------------------------------- enum Wake { Tree, Quit, PickImage, } static TO_APP: Mutex>> = Mutex::new(None); static QUIT_ASKED: AtomicBool = AtomicBool::new(false); static RAN: AtomicBool = AtomicBool::new(false); static CLOSED: AtomicBool = AtomicBool::new(false); /// The window's size in points, as libcosmic last reported it. A client that /// lays columns out by arithmetic — frq sizes its message list against the /// people panel beside it — has to be able to ask. static WINDOW_W: AtomicU32 = AtomicU32::new(0); static WINDOW_H: AtomicU32 = AtomicU32::new(0); /// Where a picture chooser opened by `cosmic_pick_image` has got to. enum Pick { Idle, Open, Chosen(PathBuf), } static PICK: Mutex = Mutex::new(Pick::Idle); /// The picture a Ctrl+V found on the clipboard, held until the worker asks for /// it with `cosmic_clipboard_image_png`. static CLIPBOARD_PNG: Mutex>> = Mutex::new(None); /// The clipboard read as PNG. Only image/png is asked for: every desktop that /// puts a picture on a clipboard puts one there as PNG too. struct ClipboardPng(Vec); impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ClipboardPng { fn allowed() -> std::borrow::Cow<'static, [String]> { std::borrow::Cow::Owned(vec!["image/png".to_owned()]) } } impl TryFrom<(Vec, String)> for ClipboardPng { type Error = (); fn try_from((bytes, _mime): (Vec, String)) -> Result { if bytes.is_empty() { Err(()) } else { Ok(Self(bytes)) } } } /// Named for emoji rather than left to fallback: the first face with a glyph /// for a smiley is often a monochrome one, and the pill then shows an outline. const EMOJI_FONT: Font = Font::with_name("Noto Color Emoji"); fn tell_app(wake: Wake) { if let Some(tx) = lock(&TO_APP).as_ref() { let _ = tx.unbounded_send(wake); } } /// The subscription's stream. It opens with a `Tree` wake so a commit made /// between `init` and the subscription starting is not missed, and repeats a /// quit asked for before there was anyone to tell. fn wakes() -> impl Stream { let (tx, rx) = mpsc::unbounded(); let _ = tx.unbounded_send(Wake::Tree); if QUIT_ASKED.load(SeqCst) { let _ = tx.unbounded_send(Wake::Quit); } *lock(&TO_APP) = Some(tx); rx.map(|wake| match wake { Wake::Tree => Message::Tree, Wake::Quit => Message::Quit, Wake::PickImage => Message::PickImage, }) } // --- scroll areas ------------------------------------------------------------ /// Where a scroll area was left, kept by name rather than on the widget. /// /// iced keeps a scrollable's offset in its widget tree, and a widget that is /// unmounted and mounted again starts at the top. glimmer clients unmount /// lists all the time — frq's lightbox is a screen, so looking at a picture /// takes the backlog away — so the place is remembered here, under the /// `scroll-key` the client names the list by, and put back when it returns. struct ScrollMemo { /// Whether the reader is at the newest line. A `stick-to-bottom` list /// follows what arrives only while this holds. at_end: bool, /// What the client was last told about that, and `None` while it has been /// told nothing. /// /// Held apart from `at_end` because the two answer different questions. /// `at_end` is where this list is; `told` is what the client believes, /// and a report is worth making exactly when they differ. Reporting on a /// change in `at_end` alone loses two cases, and both of them end with a /// "jump to present" button over a backlog that is already at its newest /// line. A list mounting says nothing, because the place it opens at is /// the place the memo guessed it would — but the client's belief is about /// the list this one REPLACED, which in frq is another room entirely. And /// a jump says nothing, because the branch that asked for it marked the /// memo on the way past, so the report that came back agreed with it. told: Option, offset_y: f32, /// How tall the viewport was when the reader last moved it, which is what /// a jump centres a row in. Zero until they have: a list nobody has /// scrolled has no reported height, and a row put in the middle of a /// viewport of nothing is a row put at the top — which is the right answer /// to give when the height is not known. height: f32, } /// Two points of slack: a viewport scrolled to its end by a fractional /// offset is still at the end. const AT_END_SLACK: f32 = 2.0; fn scroll_name(n: &Node, id: i32) -> String { match n.str("scroll-key") { "" => format!("node-{id}"), key => key.to_owned(), } } fn scroll_id(name: &str) -> widget::Id { widget::Id::new(format!("jolt-scroll-{name}")) } fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) { if let Some(n) = t.get(id) { f(id, n); for child in &n.children { walk(t, *child, f); } } } /// What a commit asks of one scroll area. struct ScrollAsk { name: String, stick: bool, /// The `scroll-to-bottom` counter, and what it was in the tree before. tick: Option, tick_before: Option, /// Not in the tree before this commit: mounted, or mounted again. fresh: bool, /// The row asking to be shown, if one is. reveal: Option, } /// Every scroll area in `now`, and what changed about each since `before`. fn scroll_asks(before: &Tree, now: &Tree) -> Vec { let mut named_before: HashMap> = HashMap::new(); walk(before, before.root_id(), &mut |id, n| { if n.tag == "scroll" { named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom")); } }); let mut asks = Vec::new(); walk(now, now.root_id(), &mut |id, n| { if n.tag != "scroll" { return; } let name = scroll_name(n, id); // The row holding whatever asked to be shown. // // The row, because a row is what `rows::Rows` writes a place down for; // and the row rather than a guess at where it sits, because this used // to answer with its index over the row count. That is a fraction of // the scroll RANGE and not of the content — the two agree only when // the viewport is exactly one row tall — and it took every row for the // same height besides, in a backlog that puts a one-line message next // to a picture. The landing was out by up to a viewport, worst in the // middle of a list. // // While it is asking, not only on the commit the ask arrives. A row // that is not laid out yet has no place written down for it, and the // ask is over in half a second: asking again each commit is what lets // a jump into a conversation the client has only just switched to land // on the frame the rows finally exist. let mut reveal = None; for row in &n.children { let mut asked = false; walk(now, *row, &mut |_, node| { asked |= node.bool("scroll-here") == Some(true); }); if asked { reveal = Some(*row); break; } } asks.push(ScrollAsk { fresh: !named_before.contains_key(&name), tick_before: named_before.get(&name).copied().flatten(), tick: n.num("scroll-to-bottom"), stick: n.bool("stick-to-bottom") == Some(true), reveal, name, }); }); asks } /// What a commit asks of one scroll area, decided. #[derive(Debug, PartialEq)] enum ScrollMove { /// Show this row of it. Reveal(i32), /// Take it to its newest line. End, /// Put it back where the reader left it, in points. Restore(f32), /// Leave it alone. Stay, } /// What to do with one scroll area, and the memo brought up to date. /// /// Split out from `take_tree` because it is the whole of the thinking and none /// of the toolkit: everything here is the ask beside what is remembered, so it /// can be read — and tested — without a window to put it in. fn scroll_move(ask: &ScrollAsk, memo: &mut ScrollMemo) -> ScrollMove { // A list this tree did not have a moment ago is a list the client has // heard nothing about, whatever it heard about the last one under this // name. Forgetting what it was told is what makes the next report happen, // so that what it believes is about the list it is looking at — in frq, // the room it is in rather than the room it came from. if ask.fresh { memo.told = None; } let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before; // A row is asking to be shown. Whether or not it can be shown yet, // nothing else may move this list while it is asking: the branch below // would otherwise take a reader who was at the newest line — which is // most readers, most of the time — straight back to it, and a jump that // ends at the bottom of the room reads as a jump that did nothing. // // Nothing below marks the memo, either. Where a list lands is `report`'s // to hear from the toolkit and pass on; a memo that wrote the answer down // here would agree with the report when it came and keep it from the // client — the list moved, nobody was told, and the client went on // believing whatever it believed before. Which is a "jump to present" // button over a backlog that is already at its newest line. if let Some(row) = ask.reveal { ScrollMove::Reveal(row) } else if jumped || (ask.stick && memo.at_end) { ScrollMove::End } else if ask.fresh { ScrollMove::Restore(memo.offset_y) } else { ScrollMove::Stay } } /// What to tell the client now that this list is at `at_end`, if anything. /// /// Against what it was last told rather than against where the list was a /// moment ago. The two are the same answer for a list the reader is moving by /// hand, and they part company wherever something else moved it — see `told`. fn report(memo: &mut ScrollMemo, at_end: bool) -> Option<&'static str> { (memo.told != Some(at_end)).then(|| { memo.told = Some(at_end); // "end" or "away", the strings libvidya emits: frq's handler compares // against "end". if at_end { "end" } else { "away" } }) } /// Where the rows of the scroll area called `name` were last laid out. /// /// By name and not by node, because a scroll area outlives the node ids of a /// tree that is rebuilt under it — the same list, and the reader's place in /// it, is the thing `scroll-key` names. fn placements(name: &str) -> rows::Placements { static BOOKS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); lock(&BOOKS).entry(name.to_owned()).or_default().clone() } /// Where to scroll so that a row at `top`, `height` tall, sits in the middle /// of a viewport `viewport` tall. /// /// The middle rather than the top edge: a line answered three days ago is read /// with what was said around it, and a jump that pins it to the ceiling shows /// only what came after. /// /// Never above the start of the content — a negative offset is not a place — /// and the top edge is the answer while the viewport's height is unknown, /// which it is until the reader has scrolled the list once. A row centred in a /// viewport of nothing is a row at the top, which is the same answer said /// twice, but it is worth being the one that is said on purpose. fn centred_offset(top: f32, height: f32, viewport: f32) -> f32 { (top - (viewport - height).max(0.0) / 2.0).max(0.0) } /// Whether to say out loud what every jump decided, on stderr. /// /// Set `JOLT_SCROLL_LOG` to anything. A jump is three numbers and a lookup, /// and which of them is wrong is not a thing anyone can tell from a window /// that scrolled to the wrong place. fn scroll_log() -> bool { static ON: LazyLock = LazyLock::new(|| std::env::var_os("JOLT_SCROLL_LOG").is_some()); *ON } /// How many frames a reveal keeps trying for. /// /// A row is measured by the layout that draws it, so the frame a jump is asked /// on is a frame too early: the places written down are the ones from before /// the room changed. Twenty frames is a third of a second at sixty, which is /// longer than a screen takes to build and shorter than a reader waits before /// deciding nothing happened. const REVEAL_TRIES: u8 = 20; /// How many frames a list that has just mounted keeps asking for the place it /// opens at. /// /// `REVEAL_TRIES`' reasoning at the other end of the same problem. iced runs a /// widget operation against the tree the last `view` built, so the ask that /// goes out on the commit a scroll area ARRIVES on can find no such widget to /// act on — the scrollable it names is one this frame is only now building. /// Whether it lands is then a question of which happens first, which is not a /// question a room should open on: most of the time the conversation opened at /// its newest line and sometimes it opened at the top of the backlog. /// /// Four frames rather than `reveal`'s twenty, and it stops the moment the list /// reports that it arrived. A mount settles in one or two; what is left of the /// budget after that is time spent fighting a reader who opened a room and /// immediately scrolled, and there is no reason to spend more of it than the /// mount actually needs. const SETTLE_TRIES: u8 = 4; /// Whether the list called `name` is already where a mount sent it — its end /// for `None`, that offset for `Some`. /// /// Read off the memo, which is to say off what the toolkit last reported, so /// a list nobody has heard from yet has not arrived and is asked again. fn settled(memo: Option<&ScrollMemo>, want: Option) -> bool { match (memo, want) { (None, _) => false, (Some(m), None) => m.at_end, (Some(m), Some(y)) => (m.offset_y - y).abs() <= AT_END_SLACK, } } /// The row of the scroll area called `name` that is asking to be shown, as the /// tree has it now. /// /// Asked again on every attempt rather than carried, because a row is not the /// same node for long. A buffer that takes a line while a jump is landing is /// rebuilt under the reconciler, and the row that was node 412 a frame ago is /// node 587 now — so a retry holding the old number would look up a place for /// a row nobody has, and go on failing until it gave up. Which room a reader /// jumped into decided whether it worked, and that is exactly as strange as /// it sounds until you see what it depends on. fn asking_row(t: &Tree, name: &str) -> Option { let mut found = None; walk(t, t.root_id(), &mut |id, n| { if found.is_some() || n.tag != "scroll" || scroll_name(n, id) != name { return; } for row in &n.children { let mut asked = false; walk(t, *row, &mut |_, node| { asked |= node.bool("scroll-here") == Some(true); }); if asked { found = Some(*row); break; } } }); found } /// Ask to be taken to the row of `name` that wants showing — now if its place /// is known, and on the next frame if it is not. /// /// A task that is already finished is not a wasted frame: iced takes its /// message on the next pass of the loop, which is after this frame has been /// laid out — and being laid out is exactly what the row has to have done for /// there to be an answer. fn reveal(name: String, row: i32, viewport: f32) -> Task { match placements(&name).get(row) { Some((top, height)) => { let y = centred_offset(top, height, viewport); if scroll_log() { eprintln!( "jolt-scroll: {name} row {row} at {top} (h {height}), viewport {viewport} -> {y}" ); } iced_scrollable::scroll_to( scroll_id(&name), AbsoluteOffset { x: None, y: Some(y), }, ) } None => { if scroll_log() { eprintln!("jolt-scroll: {name} row {row} has no place yet, trying again"); } Task::future(async move { cosmic::Action::App(Message::Reveal(name, REVEAL_TRIES)) }) } } } /// How many rows the scroll area called `name` has, and how many of them are /// asking to be shown. For the log alone. fn scroll_shape(t: &Tree, name: &str) -> (usize, usize) { let mut shape = (0, 0); walk(t, t.root_id(), &mut |id, n| { if shape.0 > 0 || n.tag != "scroll" || scroll_name(n, id) != name { return; } shape.0 = n.children.len(); for row in &n.children { let mut asked = false; walk(t, *row, &mut |_, node| { asked |= node.bool("scroll-here") == Some(true); }); if asked { shape.1 += 1; } } }); shape } fn snap_to_end(name: &str) -> Task { iced_scrollable::snap_to( scroll_id(name), RelativeOffset { x: None, y: Some(1.0), }, ) } // --- the app ----------------------------------------------------------------- struct App { core: Core, tree: Arc, scrolls: HashMap, /// What the window last wrote back into a control, by node and prop, with /// the sequence number of the event that carried it to the worker. typed: HashMap<(i32, &'static str), (u64, Prop)>, } #[derive(Clone, Debug)] enum Message { Tree, Quit, Click(i32), Toggled(i32, bool), Change(i32, String), Paste(i32, String), PastedPicture(i32, Option>), Activate(i32), Hover(i32), Unhover(i32), Scrolled(i32, String, Viewport), /// Show whichever row of this scroll area is asking to be shown, and how /// many more frames to keep trying for. See `reveal`. Reveal(String, u8), /// Put this scroll area where it opened at — its end for `None`, that /// offset for `Some` — again, and how many more frames to keep at it. /// See `SETTLE_TRIES`. Settle(String, Option, u8), PickImage, Picked(Option), } /// Lay what was typed over a commit that has not caught up with it. /// /// libcosmic paints a control from the tree, so a commit rendered before the /// worker saw the latest keystroke would put the older text back under the /// caret, and the next key would land on that. An entry is let go once a /// commit was rendered after its event: from then on the component's own /// state is the answer, a draft it cleared included. fn keep_typed( tree: &mut Arc, typed: &mut HashMap<(i32, &'static str), (u64, Prop)>, settled: u64, ) { typed.retain(|&(node, key), (seq, value)| { let Some(n) = tree.get(node) else { return false; }; if *seq <= settled { return false; } if n.props.get(key) != Some(value) { Arc::make_mut(tree).set(node, key, value.clone()); } true }); } impl App { /// A widget does not own its value: the new state goes into the arena and /// into what is painted, so a caller that ignores the event still sees a /// working control, and its next render is what settles it. Then the event /// goes to the worker, and what was written is held over any commit /// rendered before the worker saw it. fn write_back( &mut self, node: i32, key: &'static str, value: Prop, event: &'static str, text: String, num: f64, ) { edit(|t| t.set(node, key, value.clone())); Arc::make_mut(&mut self.tree).set(node, key, value.clone()); let seq = post_seq(node, event, text, num); self.typed.insert((node, key), (seq, value)); } /// Take the committed tree, and move every scroll area to where it should /// be now that it has changed. /// /// A snap is relative, so a list snapped to its end stays at its end as /// rows arrive under it, until the reader scrolls away. fn take_tree(&mut self) -> Task { let (committed, settled) = { let c = lock(&COMMITTED); (c.clone(), COMMITTED_SETTLED.load(SeqCst)) }; let before = std::mem::replace(&mut self.tree, committed); keep_typed(&mut self.tree, &mut self.typed, settled); let mut tasks = Vec::new(); let mut live = HashSet::new(); for ask in scroll_asks(&before, &self.tree) { live.insert(ask.name.clone()); let memo = self.scrolls.entry(ask.name.clone()).or_insert(ScrollMemo { at_end: ask.stick, told: None, offset_y: 0.0, height: 0.0, }); // A row asking to be shown is measured on the frame it appears, // so the ask stands until the layout has a place for it — see // `reveal`, which asks again rather than giving up. let moved = scroll_move(&ask, memo); match moved { ScrollMove::Reveal(row) => tasks.push(reveal(ask.name.clone(), row, memo.height)), ScrollMove::End => tasks.push(snap_to_end(&ask.name)), ScrollMove::Restore(y) => tasks.push(iced_scrollable::scroll_to( scroll_id(&ask.name), AbsoluteOffset { x: None, y: Some(y), }, )), ScrollMove::Stay => {} } // And keeps asking, while this is the commit the list arrived on: // there may be no widget to have heard the ask above. `reveal` // does its own asking again; the other two are asked for here. if ask.fresh { let want = match moved { ScrollMove::End => Some(None), ScrollMove::Restore(y) => Some(Some(y)), _ => None, }; if let Some(want) = want { let name = ask.name.clone(); tasks.push(Task::future(async move { cosmic::Action::App(Message::Settle(name, want, SETTLE_TRIES)) })); } } } // A list that was never scrolled keeps no memo worth the space; one // that was keeps its place for when it comes back. self.scrolls .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0); Task::batch(tasks) } fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) { let y = viewport.absolute_offset().y; let room = viewport.content_bounds().height - viewport.bounds().height; let at_end = room - y <= AT_END_SLACK; let memo = self.scrolls.entry(name).or_insert(ScrollMemo { at_end, told: None, offset_y: y, height: viewport.bounds().height, }); memo.at_end = at_end; memo.offset_y = y; memo.height = viewport.bounds().height; if let Some(place) = report(memo, at_end) { post(node, "change", place.to_owned(), 0.0); } } } impl cosmic::Application for App { type Executor = cosmic::executor::Default; type Flags = String; type Message = Message; const APP_ID: &'static str = "dev.jolt.Glimmer"; fn core(&self) -> &Core { &self.core } fn core_mut(&mut self) -> &mut Core { &mut self.core } fn init(core: Core, title: String) -> (Self, Task) { let mut app = App { core, tree: lock(&COMMITTED).clone(), scrolls: HashMap::new(), typed: HashMap::new(), }; // libcosmic's `wayland` feature brings `multi-window` with it, which // makes a window title a per-window thing. app.set_header_title(title.clone()); let task = match app.core.main_window_id() { Some(id) => app.set_window_title(title, id), None => Task::none(), }; (app, task) } fn subscription(&self) -> Subscription { Subscription::run(wakes) } fn on_window_resize(&mut self, _id: cosmic::iced::window::Id, width: f32, height: f32) { WINDOW_W.store(width.max(0.0) as u32, SeqCst); WINDOW_H.store(height.max(0.0) as u32, SeqCst); } fn update(&mut self, message: Message) -> Task { match message { Message::Tree => return self.take_tree(), Message::Quit => return cosmic::iced::exit(), Message::Click(node) => post(node, "click", String::new(), 0.0), Message::Toggled(node, on) => { let num = f64::from(u8::from(on)); self.write_back( node, "active", Prop::Bool(on), "toggled", String::new(), num, ); } Message::Change(node, text) => { self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0); } // libcosmic's field answers Ctrl+V with the clipboard's text, and a // clipboard holding a picture has none, so the field comes back as // it was. That is the paste worth reporting: the picture is read // here, where the clipboard is, and `paste-empty` goes to the // worker, which collects it with `cosmic_clipboard_image_png`. Message::Paste(node, text) => { if self.tree.get(node).is_some_and(|n| n.str("text") == text) { return cosmic::iced::clipboard::read_data::().map(move |png| { cosmic::Action::App(Message::PastedPicture(node, png.map(|p| p.0))) }); } self.write_back(node, "text", Prop::Str(text.clone()), "change", text, 0.0); } Message::PastedPicture(node, png) => { *lock(&CLIPBOARD_PNG) = png; post(node, "paste-empty", String::new(), 0.0); } Message::Activate(node) => post(node, "activate", String::new(), 0.0), Message::Hover(node) => post(node, "hover", String::new(), 0.0), Message::Unhover(node) => post(node, "unhover", String::new(), 0.0), Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport), // The row was not laid out when the jump was asked for. Look // again, and keep looking for a few frames: a room the reader has // only just been taken to has to be built before its lines have // anywhere to be. // Asked again until the list says it arrived, or the budget is // out. Idempotent either way: both asks name an absolute place, // so one that already landed lands on the same place again. Message::Settle(name, want, tries) => { if settled(self.scrolls.get(&name), want) || tries == 0 { return Task::none(); } let again = { let name = name.clone(); Task::future(async move { cosmic::Action::App(Message::Settle(name, want, tries - 1)) }) }; let now = match want { None => snap_to_end(&name), Some(y) => iced_scrollable::scroll_to( scroll_id(&name), AbsoluteOffset { x: None, y: Some(y), }, ), }; return Task::batch([now, again]); } Message::Reveal(name, tries) => { let viewport = self.scrolls.get(&name).map_or(0.0, |memo| memo.height); let place = asking_row(&self.tree, &name).and_then(|row| placements(&name).get(row)); if let Some((top, height)) = place { // Nothing written down here either, for the reason the // commit path gives: where this ends up is `scrolled`'s to // report, and its report is what the client hears. let y = centred_offset(top, height, viewport); return iced_scrollable::scroll_to( scroll_id(&name), AbsoluteOffset { x: None, y: Some(y), }, ); } if scroll_log() { let asking = asking_row(&self.tree, &name); let (rows, here) = scroll_shape(&self.tree, &name); eprintln!( "jolt-scroll: {name} retry {tries}, asking {asking:?}, \ {rows} rows, {here} asking to be shown, \ {} placed, viewport {viewport}", placements(&name).len() ); } // Not landed yet. Keep trying for the whole budget rather // than stopping the moment nothing is asking: a room the // reader has just been taken to is built over several frames, // and one where the rows are not in the tree yet looks exactly // like a jump that is over. It is not over, it is early. if tries > 0 { return Task::future(async move { cosmic::Action::App(Message::Reveal(name, tries - 1)) }); } } // The desktop's own chooser, through the portal, on libcosmic's // executor: it is a D-Bus round trip, and the window keeps // painting while it is open. Message::PickImage => { return Task::perform( async { rfd::AsyncFileDialog::new() .set_title("Choose a picture") .add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp"]) .pick_file() .await .map(|file| file.path().to_path_buf()) }, |path| cosmic::Action::App(Message::Picked(path)), ); } Message::Picked(path) => *lock(&PICK) = path.map_or(Pick::Idle, Pick::Chosen), } Task::none() } fn view(&self) -> Element<'_, Message> { let tree = &*self.tree; let root = element(tree, tree.root_id(), true, false); // A dialog that asked not to be modal, put up here rather than handed // to `dialog` below. It is the same widget in the same place — a // `popover` centres it exactly as `cosmic::app` does — and the whole // of the difference is that this one is not told to intercept the // pointer. That matters to anything the pointer opened: a modal // popover hands the window underneath it a cursor that is // `Unavailable`, so a face that opened a dialog on hover never hears // the pointer leave, and what it opened can never close itself. // // The popover is here whether or not there is anything in it, which // `cosmic::app` says of its own in one line and which this learned // the long way: iced keeps a widget's state by where it sits in the // tree, so a wrapper that comes and goes rebuilds everything under // it — and what "everything" holds is the scroll positions. Wrapping // only when a dialog appeared meant resting the pointer on a face // jumped the conversation behind it. let mut popover = widget::popover(root); if let Some(id) = find_dialog(tree, false) { // The dialog reports its own pointer, on the same two events a // face or a pill reports theirs. Without it a dialog the pointer // opened can only be read at arm's length: the client is told the // pointer left what opened it and never told it arrived here, so // the one way to keep it up is not to move — and everything in it // is out of reach. let popup = widget::mouse_area(dialog_of(tree, id)) .on_enter(Message::Hover(id)) .on_exit(Message::Unhover(id)); popover = popover.popup(popup); } popover.into() } /// The MODAL dialog the tree is carrying, if it is carrying one. /// /// A client says there is one by putting a `dialog` node in the tree and /// says there is not by leaving it out — the same way it says anything /// else. What comes back is libcosmic's own dialog: centred, over a /// dimmed window, and closed by the buttons the client hung on it. /// /// A dialog that says `modal false` does not come back here. This hook is /// the modal one whether the client wants it or not — `cosmic::app` wraps /// whatever it returns in `popover(..).modal(true)` — and `view` puts /// that kind up itself. See `dialog_of`. fn dialog(&self) -> Option> { let tree = &*self.tree; let id = find_dialog(tree, true)?; Some(dialog_of(tree, id)) } } /// The first `dialog` node in the tree whose modality is `modal`. /// /// Absent, `modal` is true: a dialog is the modal kind unless it says it is /// not, which is the shape everything else here takes — a prop left out is /// the ordinary answer. fn find_dialog(t: &Tree, modal: bool) -> Option { let mut found = None; walk(t, t.root_id(), &mut |id, n| { if found.is_none() && n.tag == "dialog" && (n.bool("modal") != Some(false)) == modal { found = Some(id); } }); found } /// One `dialog` node as libcosmic's dialog. /// /// `label` is its heading and `body` the line under it. Children are its /// controls, in order, except that a child carrying `slot` "primary" or /// "secondary" becomes that action instead — which is where libcosmic puts /// the buttons, at the foot and to the right. fn dialog_of(t: &Tree, id: i32) -> Element<'_, Message> { let Some(n) = t.get(id) else { return widget::Space::new().width(0).height(0).into(); }; let mut d = widget::dialog(); if !n.label().is_empty() { d = d.title(n.label().to_owned()); } if !n.str("body").is_empty() { d = d.body(n.str("body").to_owned()); } if let Some(w) = n.num("max-width") { d = d.max_width(w as f32); } for child in &n.children { let Some(c) = t.get(*child) else { continue }; let el = element(t, *child, true, false); d = match c.str("slot") { "primary" => d.primary_action(el), "secondary" => d.secondary_action(el), _ => d.control(el), }; } d.into() } // --- props into layout ----------------------------------------------------------- /// `margin` all round, with `margin-top` and its siblings overriding a side. fn margins(n: &Node) -> Padding { let all = n.num("margin").unwrap_or(0.0) as f32; let side = |key| n.num(key).map_or(all, |v| v as f32); Padding { top: side("margin-top"), right: side("margin-right"), bottom: side("margin-bottom"), left: side("margin-left"), } } /// A width the client asked for. Zero is the client saying "none": frq writes /// `:width-request 0` on its message column whenever the people panel is shut, /// and taken literally that is a backlog laid out zero points wide. fn width_request(n: &Node) -> Option { n.num("width-request") .filter(|w| *w > 0.0) .map(|w| w as f32) } /// `align`, or `default` where it is not set. A column starts its children at /// the left. Rows do not ask: `align` on a row is where along the row its /// children sit, not how they line up across it, and the row branch of /// `element` reads it itself. fn alignment(n: &Node, default: Alignment) -> Alignment { match n.str("align") { "start" => Alignment::Start, "center" => Alignment::Center, "end" => Alignment::End, _ => default, } } fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> { cosmic::theme::Container::custom(move |_| ContainerStyle { background: Some(Background::Color(color)), border: Border { radius: radius.into(), ..Border::default() }, text_color: Some(Color::WHITE), ..ContainerStyle::default() }) } /// A colour for somebody, from their name, so the same person is the same /// colour everywhere they appear. fn name_colour(name: &str) -> Color { const PALETTE: [(f32, f32, f32); 8] = [ (0.83, 0.33, 0.33), (0.85, 0.55, 0.20), (0.62, 0.62, 0.18), (0.30, 0.65, 0.35), (0.20, 0.62, 0.62), (0.30, 0.50, 0.85), (0.55, 0.40, 0.85), (0.80, 0.35, 0.65), ]; let hash = name .bytes() .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b))); let (r, g, b) = PALETTE[hash as usize % PALETTE.len()]; Color::from_rgb(r, g, b) } /// A picture that answers a click, with the pointer saying so. fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> { if !enabled { return el; } widget::mouse_area(el) .on_press(Message::Click(id)) .interaction(cosmic::iced::mouse::Interaction::Pointer) .into() } fn picture(path: &str) -> Option { (!path.is_empty() && std::path::Path::new(path).exists()) .then(|| widget::image::Handle::from_path(path)) } // --- the tree into widgets --------------------------------------------------------- /// One node and everything under it, as widgets. /// /// `enabled` is inherited: an insensitive container takes its whole subtree out /// of interaction. `in_row` is whether the parent lays its children out across: /// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a /// column in a column takes the width and a column in a row does not take the /// row's slack unless it says `fill-height`. fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> { let Some(n) = t.get(id) else { return Column::new().into(); }; let enabled = enabled && n.bool("sensitive") != Some(false); let fill_height = n.bool("fill-height") == Some(true); // glimmer-jvui's theme spacing, where the client does not say: a list of // cards with nothing between them reads as one slab. let spacing = n.num("spacing").unwrap_or(6.0) as f32; let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row)); let el: Element<'_, Message> = match n.tag.as_str() { "window" => Column::with_children(children(false)) .width(Length::Fill) .height(Length::Fill) .into(), // Sizes are set only where something asked for one. iced's rows and // columns take `Fill` on an axis from any child that fills it, which is // glimmer-jvui's `fills-height?` rule done for us — and an explicit // `Shrink` would throw that away, so a wrapper with no `fill-height` of // its own would hand the list inside it no height at all. "box" => { let across = n.str("orientation") == "horizontal"; if across { // `align` on a row is the MAIN axis, which is glimmer's // meaning and the one the shared screens are written against: // `:end` lays the children out *from* the right, so the first // child in the source is the rightmost on screen. Read as a // cross-axis gravity instead it did nothing visible but sit // the chips low, and the message heading's pair came out in // the order ✏️ ↩️ 🙂 hard against the clock rather than the // other way round against the edge — see `chat/action-chips`. let from_end = n.str("align") == "end"; let mut row = if from_end { Row::with_children(children(true).collect::>().into_iter().rev()) } else { Row::with_children(children(true)) } .spacing(spacing) .padding(margins(n)) // Across the row the children still centre: a chip beside a // label sitting against the top of it is what that is for. .align_y(Alignment::Center); // A row fills the width it is in only when it or something in // it asks to; otherwise a line of buttons would spread out. match width_request(n) { Some(w) => row = row.width(w), None if fill_height => row = row.width(Length::Fill), None => {} } if fill_height { row = row.height(Length::Fill); } if from_end { // iced has no main-axis alignment on a Row, so the edge is // a container's doing: it takes the width and puts the row // against the right of it. Not a leading Fill space, which // would have halved the slack with a row that already has // something filling in it — the join box beside its button // is that row, and the box is meant to take all of it. return widget::container(row) .width(Length::Fill) .align_x(Horizontal::Right) .into(); } row.into() } else { let mut column = Column::with_children(children(false)) .spacing(spacing) .padding(margins(n)) .align_x(alignment(n, Alignment::Start)); match width_request(n) { Some(w) => column = column.width(w), None if fill_height || !in_row => column = column.width(Length::Fill), None => {} } if fill_height { column = column.height(Length::Fill); } column.into() } } "page" => { let column = Column::with_children(children(false)) .spacing(n.num("spacing").unwrap_or(8.0) as f32) .padding(24) .width(Length::Fill); let mut inner = widget::container(column).width(Length::Fill); if let Some(max) = n.num("max-width") { inner = inner.max_width(max as f32); } widget::scrollable(widget::container(inner).center_x(Length::Fill)) .width(Length::Fill) .height(Length::Fill) .into() } "card" | "frame" => { let mut column = Column::new().spacing(n.num("spacing").unwrap_or(8.0) as f32); if n.tag == "frame" && !n.label().is_empty() { column = column.push(widget::text::heading(n.label())); } let card = widget::container(column.extend(children(false))) .padding(12) .class(cosmic::theme::Container::Card); match width_request(n) { Some(w) => card.width(w).into(), None if !in_row => card.width(Length::Fill).into(), None => card.into(), } } // Always fills both ways: a viewport that only fills its width asks its // column for no height, and is given none. The content is held to its // own height, since iced will not scroll content that fills the axis it // scrolls along. "scroll" => { let name = scroll_name(n, id); let content = Column::with_children(children(false)) .spacing(spacing) .width(Length::Fill) .height(Length::Shrink); // Wrapped in the thing that writes down where each row landed, so // that "take me to this line" has an answer in points — which is // the only thing a scroll area can be told. See `rows`. let content = rows::Rows::new(content, n.children.clone(), placements(&name)); widget::scrollable(content) .id(scroll_id(&name)) .width(Length::Fill) .height(Length::Fill) .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport)) .into() } // Word wrapping that falls back to breaking inside a word: a URL is one // word, and it otherwise runs straight past the edge of its column. "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()) .wrapping(Wrapping::WordOrGlyph) .into(), "label" => widget::text::body(n.label()) .wrapping(Wrapping::WordOrGlyph) .into(), "title" => widget::text::title3(n.label()) .wrapping(Wrapping::WordOrGlyph) .into(), "title-2" => widget::text::title4(n.label()) .wrapping(Wrapping::WordOrGlyph) .into(), "dim-label" => widget::text::caption(n.label()) .wrapping(Wrapping::WordOrGlyph) .into(), "button" => { let button = match n.str("kind") { "primary" => widget::button::suggested(n.label()), "destructive" => widget::button::destructive(n.label()), _ => widget::button::standard(n.label()), }; button .on_press_maybe(enabled.then_some(Message::Click(id))) .into() } "link" => widget::button::link(n.label().to_owned()) .on_press_maybe(enabled.then_some(Message::Click(id))) .into(), // A dot that says whether the thing is live, and the words beside it. "status" => { let colour = if n.bool("live") == Some(true) { Color::from_rgb(0.30, 0.72, 0.40) } else { Color::from_rgb(0.55, 0.55, 0.55) }; let dot = widget::container(widget::Space::new().width(8).height(8)) .class(filled(colour, 4.0)); Row::new() .spacing(6) .align_y(Alignment::Center) .push(dot) .push(widget::text::caption(n.label())) .into() } "spinner" => { let mut row = Row::new() .spacing(8) .align_y(Alignment::Center) .push(widget::progress_bar::indeterminate_circular().size(16.0)); if !n.label().is_empty() { row = row.push(widget::text::caption(n.label())); } row.into() } "emoji" => { let glyph = match n.str("emoji") { "" => n.label(), e => e, }; widget::text(glyph.to_owned()) .size(n.num("size").unwrap_or(16.0) as f32) .font(EMOJI_FONT) .into() } // A round picture, or the initial on a colour from the name: most // people in most rooms have no picture, so the initial IS the avatar. // // And the three things a face is for besides being looked at. It // painted as a picture and nothing else until now: a client that // asked a face to answer a click, to report the pointer arriving, or // to carry a card under it was handed a portrait that did none of // them — so the profile behind every avatar in the window was // unreachable, and the hover card written for it never appeared. // Those are the same three things `reaction` below does, so they are // done the same way: `mouse_area` for the press and the two edges of // the hover, and a `tooltip` for whatever was hung underneath. "avatar" => { let size = n.num("size").unwrap_or(32.0) as f32; let face: Element<'_, Message> = match picture(n.str("src")) { Some(handle) => widget::image(handle) .width(size) .height(size) .content_fit(ContentFit::Cover) .border_radius(size / 2.0) .into(), None => { let initial: String = n .label() .trim_start_matches(|c: char| !c.is_alphanumeric()) .chars() .next() .map(|c| c.to_uppercase().collect()) .unwrap_or_default(); widget::container(widget::text(initial).size(size * 0.45)) .center(Length::Fixed(size)) .class(filled(name_colour(n.label()), size / 2.0)) .into() } }; // The hover is reported whether or not the face is enabled: it // says where the pointer is, which is true of an insensitive // picture too. The press is not — an insensitive subtree is out // of interaction, which is what `enabled` means here. let mut area = widget::mouse_area(face) .on_enter(Message::Hover(id)) .on_exit(Message::Unhover(id)); if enabled { area = area .on_press(Message::Click(id)) .interaction(cosmic::iced::mouse::Interaction::Pointer); } if n.children.is_empty() { area.into() } else { widget::tooltip( area, Column::with_children(children(false)).spacing(4), widget::tooltip::Position::Bottom, ) .into() } } // A pill: an emoji, how many people, and whether you are one of them. // What the client hangs under it is its hover card, shown while the // pointer is on the pill. "reaction" => { let glyph = match n.str("emoji") { "" => n.label(), e => e, }; let size = n.num("size").unwrap_or(16.0) as f32; let mut content = Row::new() .spacing(4) .align_y(Alignment::Center) .push(widget::text(glyph.to_owned()).size(size).font(EMOJI_FONT)); let count = n.num("count").unwrap_or(0.0); if count > 0.0 { content = content.push(widget::text::caption(format!("{count}"))); } let class = if n.bool("mine") == Some(true) { widget::button::ButtonClass::Suggested } else { widget::button::ButtonClass::Standard }; let pill = widget::button::custom(content) .padding([2, 8]) .class(class) .on_press_maybe(enabled.then_some(Message::Click(id))); let pill = widget::mouse_area(pill) .on_enter(Message::Hover(id)) .on_exit(Message::Unhover(id)); if n.children.is_empty() { pill.into() } else { widget::tooltip( pill, Column::with_children(children(false)).spacing(4), widget::tooltip::Position::Bottom, ) .into() } } // One tag for both kinds of picture, as in libvidya. `feed` is live // pixels pushed under a name, which nothing pushes here yet, so it // holds the slot the layout gave it. "image" => { let max_w = n.num("max-width").map(|v| v as f32); let max_h = n.num("max-height").map(|v| v as f32); if !n.str("feed").is_empty() { let w = max_w.unwrap_or(160.0); let h = max_h.unwrap_or(w * 0.75); widget::container(widget::text::caption("video")) .center_x(Length::Fixed(w)) .center_y(Length::Fixed(h)) .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0)) .into() } else if let Some(handle) = picture(n.str("src")) { let mut image = widget::image(handle).content_fit(ContentFit::Contain); if n.bool("fit") == Some(true) { image = image.width(Length::Fill).height(Length::Fill); } else if let Some(size) = n.num("size") { image = image.width(size as f32).height(size as f32); } let mut bounded = widget::container(image); if let Some(w) = max_w { bounded = bounded.max_width(w); } if let Some(h) = max_h { bounded = bounded.max_height(h); } clickable(bounded.into(), id, enabled) } else { widget::Space::new().width(0).height(0).into() } } "checkbutton" => { let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label()); if enabled { check = check.on_toggle(move |on| Message::Toggled(id, on)); } check.into() } "entry" => { let mut entry = widget::text_input(n.str("placeholder"), n.str("text")); if enabled { entry = entry .on_input(move |text| Message::Change(id, text)) .on_paste(move |text| Message::Paste(id, text)) .on_submit(move |_| Message::Activate(id)); } let width = match width_request(n) { Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w), _ => Length::Fill, }; entry.width(width).into() } "separator" => widget::divider::horizontal::default().into(), "spacer" => { let size = n.num("size").unwrap_or(8.0) as f32; if n.str("expand").is_empty() { widget::Space::new().width(size).height(size).into() } else { widget::Space::new().width(Length::Fill).height(size).into() } } "progress" => { let bar = widget::progress_bar::determinate_linear(n.num("value").unwrap_or(0.0) as f32); if n.label().is_empty() { bar.into() } else { Column::new() .spacing(4) .push(widget::text::caption(n.label())) .push(bar) .into() } } // The one node that is not painted where it stands. libcosmic puts a // dialog up itself, centred over the window and dimming what is // behind it — `Application::dialog` is the hook, and it is asked for // one separately from `view`. So the tree carries the dialog wherever // the client found it convenient to write it, `App::dialog` goes and // finds it there, and this leaves nothing behind in the layout. A // node rendered in both places would be painted twice. "dialog" => widget::Space::new().width(0).height(0).into(), // Kept rather than refused, as in libvidya: a tag this backend has not // grown yet still shows its children. _ => Column::with_children(children(false)) .spacing(spacing) .padding(margins(n)) .into(), }; // The containers and the entry size themselves above; anything else asked // for a width gets it from a wrapper. match (n.tag.as_str(), width_request(n)) { ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el, (_, Some(width)) => widget::container(el).width(width).into(), } } // --- the C ABI: the loop ------------------------------------------------------- static TITLE: Mutex = Mutex::new(String::new()); /// The window's title, read when `cosmic_run` opens it. A call of its own /// because jolt will not pass a string to a `:blocking` foreign procedure, and /// `cosmic_run` has to be one. /// /// # Safety /// `title` is null or a NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn cosmic_set_title(title: *const c_char) { let title = borrowed(title); guard((), || *lock(&TITLE) = title) } /// Open the window and run libcosmic until it closes. Blocks; call it on the /// process main thread. `mode` is 0 for the system theme, 1 dark, 2 light. /// /// Answers 0 on a clean exit, 1 on an error, 2 when a window was already run /// in this process — winit's event loop cannot be made twice. #[no_mangle] pub extern "C" fn cosmic_run(width: c_int, height: c_int, mode: c_int) -> c_int { let status = guard(1, || { let title = lock(&TITLE).clone(); if RAN.swap(true, SeqCst) { log::error!("jolt-cosmic: a window already ran in this process"); return 2; } // The size asked for, until libcosmic reports the one it got. WINDOW_W.store(width.max(1) as u32, SeqCst); WINDOW_H.store(height.max(1) as u32, SeqCst); let size = cosmic::iced::Size::new(width.max(1) as f32, height.max(1) as f32); let mut settings = cosmic::app::Settings::default().size(size); match mode { 1 => settings = settings.theme(cosmic::Theme::dark()), 2 => settings = settings.theme(cosmic::Theme::light()), _ => {} } match cosmic::app::run::(settings, title) { Ok(()) => 0, Err(err) => { eprintln!("jolt-cosmic: {err}"); 1 } } }); // Outside the guard, so a panic in libcosmic still releases the worker. *lock(&TO_APP) = None; CLOSED.store(true, SeqCst); BELL.notify_all(); status } /// 1 once `cosmic_run` has returned. #[no_mangle] pub extern "C" fn cosmic_should_close() -> c_int { c_int::from(CLOSED.load(SeqCst)) } /// Close the window. Asked before the window exists, it closes on opening. #[no_mangle] pub extern "C" fn cosmic_quit() { guard((), || { QUIT_ASKED.store(true, SeqCst); tell_app(Wake::Quit); }) } /// Publish the edits since the last commit. Answers 1 when there were any. #[no_mangle] pub extern "C" fn cosmic_tree_commit() -> c_int { guard(0, || { let settled = lock(&INBOX).settled; let snapshot = { let mut e = lock(&EDITS); // A pass that only settled events still publishes, so a control // holding typed text over an older commit lets go of it. if !e.dirty && settled == COMMITTED_SETTLED.load(SeqCst) { return 0; } e.dirty = false; Arc::new(e.tree.clone()) }; { let mut committed = lock(&COMMITTED); *committed = snapshot; COMMITTED_SETTLED.store(settled, SeqCst); } tell_app(Wake::Tree); 1 }) } /// Block up to `timeout_ms` for an event, a `cosmic_wake`, or the window /// closing. Answers 1 when an event is waiting. #[no_mangle] pub extern "C" fn cosmic_wait(timeout_ms: c_int) -> c_int { guard(0, || { let timeout = Duration::from_millis(timeout_ms.max(0) as u64); let (mut inbox, _) = BELL .wait_timeout_while(lock(&INBOX), timeout, |i| { i.queue.is_empty() && !i.woken && !CLOSED.load(SeqCst) }) .unwrap_or_else(|poisoned| poisoned.into_inner()); inbox.woken = false; inbox.settled = inbox.taken; c_int::from(!inbox.queue.is_empty()) }) } /// Cut a `cosmic_wait` short — for work queued for the worker from elsewhere. #[no_mangle] pub extern "C" fn cosmic_wake() { guard((), || { lock(&INBOX).woken = true; BELL.notify_all(); }) } // --- the C ABI: the window and the desktop ----------------------------------------- /// The window's width in points; the size asked for until it has opened. #[no_mangle] pub extern "C" fn cosmic_window_width() -> c_int { WINDOW_W.load(SeqCst) as c_int } #[no_mangle] pub extern "C" fn cosmic_window_height() -> c_int { WINDOW_H.load(SeqCst) as c_int } /// Open the desktop's picture chooser. Answers 1 when it was asked for, 0 when /// there is no window to ask from; the choice arrives through /// `cosmic_picked_image`. #[no_mangle] pub extern "C" fn cosmic_pick_image() -> c_int { guard(0, || { if lock(&TO_APP).is_none() { return 0; } *lock(&PICK) = Pick::Open; tell_app(Wake::PickImage); 1 }) } /// Write the chosen picture to `path` as PNG. Answers 1 once, when a picture /// was chosen since the last call; 0 while the chooser is open, after it was /// cancelled, or when the picture could not be read. /// /// # Safety /// `path` is null or a NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn cosmic_picked_image(path: *const c_char) -> c_int { let path = borrowed(path); guard(0, || { let chosen = { let mut pick = lock(&PICK); match std::mem::replace(&mut *pick, Pick::Idle) { Pick::Chosen(chosen) => chosen, other => { *pick = other; return 0; } } }; match image::open(&chosen) .and_then(|picture| picture.save_with_format(&path, image::ImageFormat::Png)) { Ok(()) => 1, Err(err) => { eprintln!("jolt-cosmic: could not take {}: {err}", chosen.display()); 0 } } }) } /// Write the picture the last empty Ctrl+V found on the clipboard to `path`. /// Answers 1 when there was one; 0 when the clipboard held no PNG, when it was /// already taken, or when the file could not be written. /// /// # Safety /// `path` is null or a NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn cosmic_clipboard_image_png(path: *const c_char) -> c_int { let path = borrowed(path); guard(0, || { let Some(png) = lock(&CLIPBOARD_PNG).take() else { return 0; }; match std::fs::write(&*path, png) { Ok(()) => 1, Err(err) => { eprintln!("jolt-cosmic: could not write the pasted picture to {path}: {err}"); 0 } } }) } // --- the C ABI: events ----------------------------------------------------------- static EVENT_NAME: Scratch = Scratch::new(); static EVENT_TEXT: Scratch = Scratch::new(); /// Dequeue one event; 1 while there was one. The accessors describe it. #[no_mangle] pub extern "C" fn cosmic_tree_poll_event() -> c_int { guard(0, || { let mut inbox = lock(&INBOX); let next = inbox.queue.pop_front(); let got = next.is_some(); if let Some(e) = &next { inbox.taken = e.seq; } inbox.current = next; c_int::from(got) }) } #[no_mangle] pub extern "C" fn cosmic_tree_event_node() -> c_int { guard(0, || lock(&INBOX).current.as_ref().map_or(0, |e| e.node)) } #[no_mangle] pub extern "C" fn cosmic_tree_event_name() -> *const c_char { guard(empty_str(), || { EVENT_NAME.lend(lock(&INBOX).current.as_ref().map_or("", |e| e.name)) }) } #[no_mangle] pub extern "C" fn cosmic_tree_event_text() -> *const c_char { guard(empty_str(), || { let text = lock(&INBOX) .current .as_ref() .map(|e| e.text.clone()) .unwrap_or_default(); EVENT_TEXT.lend(text) }) } #[no_mangle] pub extern "C" fn cosmic_tree_event_num() -> f64 { guard(0.0, || lock(&INBOX).current.as_ref().map_or(0.0, |e| e.num)) } // --- the C ABI: nodes -------------------------------------------------------------- static PROPS: Scratch = Scratch::new(); static DUMP: Scratch = Scratch::new(); #[no_mangle] pub extern "C" fn cosmic_tree_root() -> c_int { guard(0, || edit(Tree::root)) } /// # Safety /// `tag` is null or a NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn cosmic_node_new(tag: *const c_char) -> c_int { let tag = borrowed(tag); guard(0, || edit(|t| t.new_node(&tag))) } #[no_mangle] pub extern "C" fn cosmic_node_free(node: c_int) { guard((), || edit(|t| t.free(node))) } #[no_mangle] pub extern "C" fn cosmic_node_exists(node: c_int) -> c_int { guard(0, || c_int::from(read(|t| t.exists(node)))) } /// # Safety /// `key` and `value` are null or NUL-terminated strings. #[no_mangle] pub unsafe extern "C" fn cosmic_node_set_str( node: c_int, key: *const c_char, value: *const c_char, ) { let (key, value) = (borrowed(key), borrowed(value)); guard((), || edit(|t| t.set(node, &key, Prop::Str(value)))) } /// # Safety /// `key` is null or a NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn cosmic_node_set_num(node: c_int, key: *const c_char, value: f64) { let key = borrowed(key); guard((), || edit(|t| t.set(node, &key, Prop::Num(value)))) } /// # Safety /// `key` is null or a NUL-terminated string. #[no_mangle] pub unsafe extern "C" fn cosmic_node_set_bool(node: c_int, key: *const c_char, value: c_int) { let key = borrowed(key); guard((), || edit(|t| t.set(node, &key, Prop::Bool(value != 0)))) } #[no_mangle] pub extern "C" fn cosmic_node_clear_props(node: c_int) { guard((), || edit(|t| t.clear_props(node))) } #[no_mangle] pub extern "C" fn cosmic_node_tag(node: c_int) -> *const c_char { guard(empty_str(), || { PROPS.lend(read(|t| { t.get(node).map(|n| n.tag.clone()).unwrap_or_default() })) }) } #[no_mangle] pub extern "C" fn cosmic_node_child_count(node: c_int) -> c_int { guard(0, || { read(|t| t.get(node).map_or(0, |n| n.children.len() as c_int)) }) } #[no_mangle] pub extern "C" fn cosmic_node_child_at(node: c_int, index: c_int) -> c_int { guard(0, || { read(|t| { t.get(node) .and_then(|n| n.children.get(usize::try_from(index).ok()?).copied()) .unwrap_or(0) }) }) } #[no_mangle] pub extern "C" fn cosmic_node_append(parent: c_int, child: c_int) -> c_int { guard(0, || c_int::from(edit(|t| t.append(parent, child)))) } /// Unparents AND frees `child` with everything under it. #[no_mangle] pub extern "C" fn cosmic_node_remove(parent: c_int, child: c_int) { guard((), || edit(|t| t.remove(parent, child))) } #[no_mangle] pub extern "C" fn cosmic_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int { guard(0, || { c_int::from(edit(|t| t.insert_after(parent, child, sibling))) }) } #[no_mangle] pub extern "C" fn cosmic_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int { guard(0, || { c_int::from(edit(|t| t.replace(parent, old_child, new_child))) }) } /// The subtree at `node` as hiccup; 0 is the root. #[no_mangle] pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char { guard(empty_str(), || { DUMP.lend(read(|t| { let id = if node == 0 { t.root_id() } else { node }; t.dump(id) })) }) } #[cfg(test)] mod tests { use super::*; fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 { let id = t.new_node(tag); assert!(t.append(parent, id)); id } #[test] fn typed_text_stands_over_a_commit_that_has_not_seen_it() { let mut t = Tree::default(); let root = t.root(); let entry = node(&mut t, root, "entry"); t.set(entry, "text", Prop::Str("a".into())); let mut tree = Arc::new(t); let mut typed = HashMap::new(); typed.insert((entry, "text"), (2, Prop::Str("ab".into()))); // Rendered before the worker saw the "b". keep_typed(&mut tree, &mut typed, 1); assert_eq!(tree.get(entry).unwrap().str("text"), "ab"); assert_eq!(typed.len(), 1); // Rendered after: the component cleared its draft, and that stands. Arc::make_mut(&mut tree).set(entry, "text", Prop::Str(String::new())); keep_typed(&mut tree, &mut typed, 2); assert_eq!(tree.get(entry).unwrap().str("text"), ""); assert!(typed.is_empty()); } #[test] fn a_zero_width_request_is_no_request() { let mut t = Tree::default(); let root = t.root(); let column = node(&mut t, root, "vbox"); t.set(column, "width-request", Prop::Num(0.0)); assert_eq!(width_request(t.get(column).unwrap()), None); t.set(column, "width-request", Prop::Num(260.0)); assert_eq!(width_request(t.get(column).unwrap()), Some(260.0)); } #[test] fn a_scroll_is_named_by_its_scroll_key() { let mut t = Tree::default(); let root = t.root(); let list = node(&mut t, root, "scroll"); assert_eq!( scroll_name(t.get(list).unwrap(), list), format!("node-{list}") ); t.set(list, "scroll-key", Prop::Str("messages-#freeq".into())); assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq"); } #[test] fn a_row_is_centred_in_the_viewport_without_scrolling_past_the_top() { // A row halfway down a long backlog, in a 600pt viewport: half the // viewport above it, less half the row. assert_eq!(centred_offset(1000.0, 40.0, 600.0), 1000.0 - 280.0); // The same row with no viewport reported yet: its own top. assert_eq!(centred_offset(1000.0, 40.0, 0.0), 1000.0); // A row near the top cannot be centred without scrolling above the // content, and nothing is above the content. assert_eq!(centred_offset(20.0, 40.0, 600.0), 0.0); // A row taller than the viewport is shown from its own top: there is // no middle of it to put in the middle. assert_eq!(centred_offset(500.0, 900.0, 600.0), 500.0); } #[test] fn scroll_here_asks_with_its_row_and_goes_on_asking() { let mut t = Tree::default(); let root = t.root(); let list = node(&mut t, root, "scroll"); t.set(list, "scroll-key", Prop::Str("backlog".into())); let rows: Vec = (0..5).map(|_| node(&mut t, list, "vbox")).collect(); let before = t.clone(); t.set(rows[3], "scroll-here", Prop::Bool(true)); // The row, since a row is what has a place written down for it. let asks = scroll_asks(&before, &t); assert_eq!(asks.len(), 1); assert!(!asks[0].fresh); assert_eq!(asks[0].reveal, Some(rows[3])); // And it goes on asking while the row is still asking: the row may not // have been laid out on the commit the ask arrived. let again = scroll_asks(&t, &t); assert_eq!(again[0].reveal, Some(rows[3])); // A node asking from deeper inside a row answers with the row. t.set(rows[3], "scroll-here", Prop::Bool(false)); let inner = node(&mut t, rows[1], "vbox"); t.set(inner, "scroll-here", Prop::Bool(true)); assert_eq!(scroll_asks(&t, &t)[0].reveal, Some(rows[1])); // Nothing asking, nothing to reveal. t.set(inner, "scroll-here", Prop::Bool(false)); assert_eq!(scroll_asks(&t, &t)[0].reveal, None); } /// An ask for the scroll area called `name`, with nothing going on. fn ask(name: &str) -> ScrollAsk { ScrollAsk { name: name.to_owned(), stick: true, tick: Some(0.0), tick_before: Some(0.0), fresh: false, reveal: None, } } fn memo() -> ScrollMemo { ScrollMemo { at_end: true, told: Some(true), offset_y: 0.0, height: 600.0, } } #[test] fn a_jump_asks_for_the_end_without_saying_it_arrived() { let mut m = memo(); m.at_end = false; m.told = Some(false); let mut a = ask("backlog"); a.tick = Some(1.0); assert_eq!(scroll_move(&a, &mut m), ScrollMove::End); // The memo still says what the last report said, so the report that // comes back from the toolkit is a change, and is passed on. A memo // that marked itself here would swallow it, and the client would go // on believing the reader was away from the newest line. assert!(!m.at_end); assert_eq!(report(&mut m, true), Some("end")); assert_eq!(report(&mut m, true), None); } #[test] fn a_list_that_has_just_mounted_says_where_it_is() { // The reader left this list away from the end, and the client was // told so. It comes back — another room under the same widget, or the // same room after the lightbox took the screen — and lands at the end // because it sticks there. Nothing about `at_end` CHANGED across // that, and the client still has to hear it: what it believes is // about the list this one replaced. let mut m = memo(); m.at_end = true; m.told = Some(false); let mut a = ask("backlog"); a.fresh = true; assert_eq!(scroll_move(&a, &mut m), ScrollMove::End); assert_eq!(report(&mut m, true), Some("end")); } #[test] fn a_list_nobody_moved_is_not_reported_twice() { let mut m = memo(); assert_eq!(report(&mut m, true), None); assert_eq!(report(&mut m, false), Some("away")); assert_eq!(report(&mut m, false), None); assert_eq!(report(&mut m, true), Some("end")); } #[test] fn a_row_asking_to_be_shown_beats_the_end() { let mut m = memo(); let mut a = ask("backlog"); a.reveal = Some(42); a.tick = Some(1.0); assert_eq!(scroll_move(&a, &mut m), ScrollMove::Reveal(42)); } #[test] fn a_list_coming_back_is_put_where_it_was_left() { let mut m = memo(); m.at_end = false; m.offset_y = 512.0; let mut a = ask("backlog"); a.fresh = true; assert_eq!(scroll_move(&a, &mut m), ScrollMove::Restore(512.0)); // And it is asked about again, since the client's belief is about // whatever was under this name before. assert_eq!(m.told, None); assert_eq!(report(&mut m, false), Some("away")); } #[test] fn a_mount_keeps_asking_until_the_list_says_it_arrived() { // Nothing heard from yet: the ask above may have found no widget to // act on, so it stands. assert!(!settled(None, None)); assert!(!settled(None, Some(512.0))); // Reported somewhere else: still asking. let mut m = memo(); m.at_end = false; m.offset_y = 0.0; assert!(!settled(Some(&m), None)); assert!(!settled(Some(&m), Some(512.0))); // Arrived, and the asking stops. m.at_end = true; assert!(settled(Some(&m), None)); m.offset_y = 512.0; assert!(settled(Some(&m), Some(512.0))); // Within the slack a fractional offset leaves. m.offset_y = 511.0; assert!(settled(Some(&m), Some(512.0))); m.offset_y = 480.0; assert!(!settled(Some(&m), Some(512.0))); } #[test] fn a_jump_is_the_counter_moving() { let mut t = Tree::default(); let root = t.root(); let list = node(&mut t, root, "scroll"); t.set(list, "scroll-to-bottom", Prop::Num(1.0)); let before = t.clone(); t.set(list, "scroll-to-bottom", Prop::Num(2.0)); let asks = scroll_asks(&before, &t); assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0))); } }