//! 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 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, Ordering::SeqCst}; use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; use std::time::Duration; use cosmic::app::{Core, Task}; 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); 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 { node: i32, name: &'static str, text: String, num: f64, } struct Inbox { queue: VecDeque, current: Option, woken: bool, } static INBOX: Mutex = Mutex::new(Inbox { queue: VecDeque::new(), current: None, woken: false, }); static BELL: Condvar = Condvar::new(); fn post(node: i32, name: &'static str, text: String, num: f64) { lock(&INBOX).queue.push_back(Event { node, name, text, num, }); BELL.notify_all(); } // --- 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); /// 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, offset_y: 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, /// Where, as a fraction of the list, a node that has just been asked to be /// shown sits. 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); // A row asking to be shown, that was not asking last commit. The // position is the index of the top-level row holding it: exact for a // list of rows the same height and close for frq's backlog, and there // is no layout to ask from here. let count = n.children.len(); let mut reveal = None; for (index, row) in n.children.iter().enumerate() { let mut asked = false; walk(now, *row, &mut |nid, node| { let here = node.bool("scroll-here") == Some(true); let was = before .get(nid) .is_some_and(|old| old.bool("scroll-here") == Some(true)); asked |= here && !was; }); if asked { reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32); 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 } 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, } #[derive(Clone, Debug)] enum Message { Tree, Quit, Click(i32), Toggled(i32, bool), Change(i32, String), Activate(i32), Hover(i32), Unhover(i32), Scrolled(i32, String, Viewport), PickImage, Picked(Option), } 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. fn write_back(&mut self, node: i32, key: &str, value: Prop) { edit(|t| t.set(node, key, value.clone())); Arc::make_mut(&mut self.tree).set(node, key, 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 before = std::mem::replace(&mut self.tree, lock(&COMMITTED).clone()); 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, offset_y: 0.0, }); let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before; if let Some(fraction) = ask.reveal { memo.at_end = false; tasks.push(iced_scrollable::snap_to( scroll_id(&ask.name), RelativeOffset { x: None, y: Some(fraction) }, )); } else if jumped || (ask.stick && memo.at_end) { memo.at_end = true; tasks.push(snap_to_end(&ask.name)); } else if ask.fresh { tasks.push(iced_scrollable::scroll_to( scroll_id(&ask.name), AbsoluteOffset { x: None, y: Some(memo.offset_y) }, )); } } // 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, offset_y: y, }); let was = memo.at_end; memo.at_end = at_end; memo.offset_y = y; // "end" or "away", the strings libvidya emits: frq's handler compares // against "end". if was != at_end { let place = if at_end { "end" } else { "away" }; 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(), }; // 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) => { self.write_back(node, "active", Prop::Bool(on)); post(node, "toggled", String::new(), f64::from(u8::from(on))); } Message::Change(node, text) => { self.write_back(node, "text", Prop::Str(text.clone())); post(node, "change", text, 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 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; element(tree, tree.root_id(), true, false) } } // --- 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 row centres its children on /// the cross axis by default — a label beside a button otherwise sits against /// the top of the button — and a column starts them at the left. 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 { let mut row = Row::with_children(children(true)) .spacing(spacing) .padding(margins(n)) .align_y(alignment(n, 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); } 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); 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. "avatar" => { let size = n.num("size").unwrap_or(32.0) as f32; 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() } } } // 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_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() } } // 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 snapshot = { let mut e = lock(&EDITS); if !e.dirty { return 0; } e.dirty = false; Arc::new(e.tree.clone()) }; *lock(&COMMITTED) = snapshot; 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; 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 } } }) } // --- 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(); 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 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_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() { 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)); let asks = scroll_asks(&before, &t); assert_eq!(asks.len(), 1); assert!(!asks[0].fresh); assert_eq!(asks[0].reveal, Some(0.75)); let again = scroll_asks(&t, &t); assert_eq!(again[0].reveal, None); } #[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))); } }