//! 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, AtomicU64, 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::advanced::widget as advanced; use cosmic::iced::advanced::widget::operation::scrollable as iced_operation; 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, 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, /// The node 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 and not the // asking node itself, because the row is what carries an id — see // `element` — and the id is how the layout is asked where it is. // // This used to answer with the row's index over the row count, which // is a fraction of the SCROLL RANGE rather than of the content — the // two agree only when the viewport is exactly one row tall — and it // assumed every row 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 it starts: a row that is // not in the tree yet when the ask arrives would otherwise never be // scrolled to at all, and the ask is over in half a second. 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 } fn snap_to_end(name: &str) -> Task { iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) }) } /// What a row that can be scrolled to is known by. fn here_id(node: i32) -> widget::Id { widget::Id::new(format!("jolt-here-{node}")) } /// Where `node` sits inside the scroll area called `name`, measured, and then /// the scroll put there. /// /// Two steps because the answer is not in the tree. The tree says which row /// asked; only the layout knows how far down the content it ended up, and the /// layout is a thing you can ask questions of exactly once a frame, through an /// `Operation`. So the operation reads the row's bounds and the scroll area's, /// works the offset out, and hands it back as a message that scrolls there. /// /// The row is put in the MIDDLE of the viewport rather than against its 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. fn reveal(name: String, node: i32) -> Task { let scroll = scroll_id(&name); let target = here_id(node); struct Measure { scroll: widget::Id, target: widget::Id, /// The scroll area: where its viewport starts, how tall it is, and /// how far it is scrolled already. view: Option<(f32, f32, f32)>, /// The row: where it starts and how tall it is. row: Option<(f32, f32)>, } impl advanced::Operation for Measure { fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn advanced::Operation)) { operate(self); } fn container(&mut self, id: Option<&widget::Id>, bounds: cosmic::iced::Rectangle) { if id == Some(&self.target) { self.row = Some((bounds.y, bounds.height)); } } fn scrollable( &mut self, id: Option<&widget::Id>, bounds: cosmic::iced::Rectangle, _content: cosmic::iced::Rectangle, translation: cosmic::iced::Vector, _state: &mut dyn iced_operation::Scrollable, ) { if id == Some(&self.scroll) { self.view = Some((bounds.y, bounds.height, translation.y)); } } fn finish(&self) -> advanced::operation::Outcome { match (self.view, self.row) { // Both are in window coordinates, so the row's place in the // content is how far it is below the viewport's top edge plus // how far the viewport has already been scrolled. (Some((view_y, view_h, scrolled)), Some((row_y, row_h))) => { let top = row_y - view_y + scrolled; let centred = top - (view_h - row_h).max(0.0) / 2.0; advanced::operation::Outcome::Some(centred.max(0.0)) } // The row is not laid out yet — it has only just been mounted, // or it is not in this scroll area at all. Nothing to say, and // the next commit asks again. _ => advanced::operation::Outcome::None, } } } advanced::operate(Measure { scroll, target, view: None, row: None, }) .map(move |y| cosmic::Action::App(Message::Revealed(name.clone(), y))) } // --- 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), /// A scroll area, and how far down its content the row that asked to be /// shown was measured to be. Revealed(String, f32), 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, offset_y: 0.0, }); let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before; if let Some(node) = ask.reveal { memo.at_end = false; tasks.push(reveal(ask.name.clone(), node)); } 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(), 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 measurement came back: put the scroll where it says. The // memo is written too, so a list that is unmounted and comes back // opens where the jump left it rather than where it was before. Message::Revealed(name, y) => { let id = scroll_id(&name); if let Some(memo) = self.scrolls.get_mut(&name) { memo.at_end = false; memo.offset_y = y; } return iced_scrollable::scroll_to(id, AbsoluteOffset { x: None, y: Some(y) }); } // 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 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. // // 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(), }; // Every row of a scroll area carries an id, so the operation in `reveal` // can find out where one of them actually is. // // Every row, and not only the row that is asking to be shown. iced tells // two widgets apart by their state's type, and a wrapper with no state of // its own is indistinguishable from the column it wraps — so a wrapper // that came and went as a row became the target would have the old tree // reused for the new shape, and the state under it would be read as // something it is not. That is not a wrong answer, it is a panic in the // middle of a layout. Whether a node is a row of a scroll area is a // question about where it sits, and where it sits does not change under // it. let el = match t.get(n.parent) { Some(parent) if parent.tag == "scroll" => widget::id_container(el, here_id(id)).into(), _ => el, }; // 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 scroll_here_asks_with_the_row_itself_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, not a guess at where it sits: only the layout knows that, // and `reveal` is what goes and asks it. Here the row and the node // asking are the same; a message row asks from inside itself, and the // answer is still the row, since the row is what carries an id. 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 asks again while the row is still asking. It used to ask only // on the commit the prop arrived, which meant a row not laid out yet — // a conversation the client has only just switched to — was never // scrolled to at all. let again = scroll_asks(&t, &t); assert_eq!(again[0].reveal, Some(rows[3])); // Nothing asking, nothing to reveal. t.set(rows[3], "scroll-here", Prop::Bool(false)); let quiet = scroll_asks(&t, &t); assert_eq!(quiet[0].reveal, None); // And a node asking from deeper inside a row still answers with the // row: that is the thing the layout can be asked about. let inner = node(&mut t, rows[1], "vbox"); t.set(inner, "scroll-here", Prop::Bool(true)); let deep = scroll_asks(&t, &t); assert_eq!(deep[0].reveal, Some(rows[1])); } #[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))); } }