nandi/jolt-nativepublic Fork 0
7703c75
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Paint the rest of frq's tags in glimmer-cosmic

The spike painted a short list of tags and laid every container out at its
own size, so frq's backlog came out zero points tall: :width-request 0 was
taken as a width, and an explicit Shrink on each box threw away iced's fill
propagation, which is what :fill-height needs to reach the window.

Boxes now size only on request and let iced carry Fill up. :scroll fills both
ways, follows new rows at the end, jumps on :scroll-to-bottom, keeps its place
by :scroll-key across a remount, reports "end"/"away", and moves to a
:scroll-here row by index. New tags: :link :status :spinner :image :avatar
:emoji :reaction, with hover/unhover events, and the image codecs iced was
built without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T02:57:35-07:00 Browse files
7703c75 parent: 6a3304d
modified Cargo.lock +15 -1
@@ -3218,6 +3218,16 @@ dependencies = [
32183218 "weezl",
32193219 ]
32203220
3221+[[package]]
3222+name = "gif"
3223+version = "0.14.2"
3224+source = "registry+https://github.com/rust-lang/crates.io-index"
3225+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
3226+dependencies = [
3227+ "color_quant",
3228+ "weezl",
3229+]
3230+
32213231 [[package]]
32223232 name = "gimli"
32233233 version = "0.32.3"
@@ -4239,6 +4249,9 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
42394249 dependencies = [
42404250 "bytemuck",
42414251 "byteorder-lite",
4252+ "color_quant",
4253+ "gif 0.14.2",
4254+ "image-webp",
42424255 "moxcms",
42434256 "num-traits",
42444257 "png 0.18.1",
@@ -4832,6 +4845,7 @@ dependencies = [
48324845 name = "jolt-cosmic"
48334846 version = "0.1.0"
48344847 dependencies = [
4848+ "image",
48354849 "jolt-abi",
48364850 "libcosmic",
48374851 "log",
@@ -7887,7 +7901,7 @@ version = "0.45.1"
78877901 source = "registry+https://github.com/rust-lang/crates.io-index"
78887902 checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
78897903 dependencies = [
7890- "gif",
7904+ "gif 0.13.3",
78917905 "image-webp",
78927906 "log",
78937907 "pico-args",
@@ -3218,6 +3218,16 @@ dependencies = [
3218 "weezl",3218 "weezl",
3219 ]3219 ]
3220 3220
3221+[[package]]
3222+name = "gif"
3223+version = "0.14.2"
3224+source = "registry+https://github.com/rust-lang/crates.io-index"
3225+checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
3226+dependencies = [
3227+ "color_quant",
3228+ "weezl",
3229+]
3230+
3221 [[package]]3231 [[package]]
3222 name = "gimli"3232 name = "gimli"
3223 version = "0.32.3"3233 version = "0.32.3"
@@ -4239,6 +4249,9 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
4239 dependencies = [4249 dependencies = [
4240 "bytemuck",4250 "bytemuck",
4241 "byteorder-lite",4251 "byteorder-lite",
4252+ "color_quant",
4253+ "gif 0.14.2",
4254+ "image-webp",
4242 "moxcms",4255 "moxcms",
4243 "num-traits",4256 "num-traits",
4244 "png 0.18.1",4257 "png 0.18.1",
@@ -4832,6 +4845,7 @@ dependencies = [
4832 name = "jolt-cosmic"4845 name = "jolt-cosmic"
4833 version = "0.1.0"4846 version = "0.1.0"
4834 dependencies = [4847 dependencies = [
4848+ "image",
4835 "jolt-abi",4849 "jolt-abi",
4836 "libcosmic",4850 "libcosmic",
4837 "log",4851 "log",
@@ -7887,7 +7901,7 @@ version = "0.45.1"
7887 source = "registry+https://github.com/rust-lang/crates.io-index"7901 source = "registry+https://github.com/rust-lang/crates.io-index"
7888 checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"7902 checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
7889 dependencies = [7903 dependencies = [
7890- "gif",7904+ "gif 0.13.3",
7891 "image-webp",7905 "image-webp",
7892 "log",7906 "log",
7893 "pico-args",7907 "pico-args",
modified crates/jolt-cosmic/Cargo.toml +6 -0
@@ -22,6 +22,12 @@ crate-type = ["cdylib", "rlib"]
2222 jolt-abi.workspace = true
2323 log.workspace = true
2424
25+# Decoders for `:image` and `:avatar`. libcosmic builds iced with
26+# `image-without-codecs`, so a picture loaded by path decodes to nothing unless
27+# something turns the formats on; this is the same `image` crate iced links, so
28+# naming the features here is enough.
29+image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
30+
2531 # Pinned to the rev the COSMIC applet in wayland-bar already builds against.
2632 [dependencies.libcosmic]
2733 git = "https://github.com/pop-os/libcosmic"
@@ -22,6 +22,12 @@ crate-type = ["cdylib", "rlib"]
22 jolt-abi.workspace = true22 jolt-abi.workspace = true
23 log.workspace = true23 log.workspace = true
24 24
25+# Decoders for `:image` and `:avatar`. libcosmic builds iced with
26+# `image-without-codecs`, so a picture loaded by path decodes to nothing unless
27+# something turns the formats on; this is the same `image` crate iced links, so
28+# naming the features here is enough.
29+image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
30+
25 # Pinned to the rev the COSMIC applet in wayland-bar already builds against.31 # Pinned to the rev the COSMIC applet in wayland-bar already builds against.
26 [dependencies.libcosmic]32 [dependencies.libcosmic]
27 git = "https://github.com/pop-os/libcosmic"33 git = "https://github.com/pop-os/libcosmic"
modified crates/jolt-cosmic/src/lib.rs +550 -32
@@ -23,7 +23,7 @@ mod tree;
2323
2424 pub use tree::{Node, Prop, Tree};
2525
26-use std::collections::VecDeque;
26+use std::collections::{HashMap, HashSet, VecDeque};
2727 use std::ffi::{c_char, c_int};
2828 use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
2929 use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
@@ -32,7 +32,9 @@ use std::time::Duration;
3232 use cosmic::app::{Core, Task};
3333 use cosmic::iced::futures::channel::mpsc;
3434 use cosmic::iced::futures::{Stream, StreamExt};
35-use cosmic::iced::{Alignment, Length, Subscription};
35+use cosmic::iced::widget::container::Style as ContainerStyle;
36+use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
37+use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Length, Padding, Subscription};
3638 use cosmic::widget::{self, Column, Row};
3739 use cosmic::{ApplicationExt, Element};
3840 use jolt_abi::{borrowed, empty_str, guard, Scratch};
@@ -135,11 +137,117 @@ fn wakes() -> impl Stream<Item = Message> {
135137 })
136138 }
137139
140+// --- scroll areas ------------------------------------------------------------
141+
142+/// Where a scroll area was left, kept by name rather than on the widget.
143+///
144+/// iced keeps a scrollable's offset in its widget tree, and a widget that is
145+/// unmounted and mounted again starts at the top. glimmer clients unmount
146+/// lists all the time — frq's lightbox is a screen, so looking at a picture
147+/// takes the backlog away — so the place is remembered here, under the
148+/// `scroll-key` the client names the list by, and put back when it returns.
149+struct ScrollMemo {
150+ /// Whether the reader is at the newest line. A `stick-to-bottom` list
151+ /// follows what arrives only while this holds.
152+ at_end: bool,
153+ offset_y: f32,
154+}
155+
156+/// Two points of slack: a viewport scrolled to its end by a fractional
157+/// offset is still at the end.
158+const AT_END_SLACK: f32 = 2.0;
159+
160+fn scroll_name(n: &Node, id: i32) -> String {
161+ match n.str("scroll-key") {
162+ "" => format!("node-{id}"),
163+ key => key.to_owned(),
164+ }
165+}
166+
167+fn scroll_id(name: &str) -> widget::Id {
168+ widget::Id::new(format!("jolt-scroll-{name}"))
169+}
170+
171+fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
172+ if let Some(n) = t.get(id) {
173+ f(id, n);
174+ for child in &n.children {
175+ walk(t, *child, f);
176+ }
177+ }
178+}
179+
180+/// What a commit asks of one scroll area.
181+struct ScrollAsk {
182+ name: String,
183+ stick: bool,
184+ /// The `scroll-to-bottom` counter, and what it was in the tree before.
185+ tick: Option<f64>,
186+ tick_before: Option<f64>,
187+ /// Not in the tree before this commit: mounted, or mounted again.
188+ fresh: bool,
189+ /// Where, as a fraction of the list, a node that has just been asked to be
190+ /// shown sits.
191+ reveal: Option<f32>,
192+}
193+
194+/// Every scroll area in `now`, and what changed about each since `before`.
195+fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
196+ let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
197+ walk(before, before.root_id(), &mut |id, n| {
198+ if n.tag == "scroll" {
199+ named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
200+ }
201+ });
202+
203+ let mut asks = Vec::new();
204+ walk(now, now.root_id(), &mut |id, n| {
205+ if n.tag != "scroll" {
206+ return;
207+ }
208+ let name = scroll_name(n, id);
209+ // A row asking to be shown, that was not asking last commit. The
210+ // position is the index of the top-level row holding it: exact for a
211+ // list of rows the same height and close for frq's backlog, and there
212+ // is no layout to ask from here.
213+ let count = n.children.len();
214+ let mut reveal = None;
215+ for (index, row) in n.children.iter().enumerate() {
216+ let mut asked = false;
217+ walk(now, *row, &mut |nid, node| {
218+ let here = node.bool("scroll-here") == Some(true);
219+ let was = before
220+ .get(nid)
221+ .is_some_and(|old| old.bool("scroll-here") == Some(true));
222+ asked |= here && !was;
223+ });
224+ if asked {
225+ reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);
226+ break;
227+ }
228+ }
229+ asks.push(ScrollAsk {
230+ fresh: !named_before.contains_key(&name),
231+ tick_before: named_before.get(&name).copied().flatten(),
232+ tick: n.num("scroll-to-bottom"),
233+ stick: n.bool("stick-to-bottom") == Some(true),
234+ reveal,
235+ name,
236+ });
237+ });
238+ asks
239+}
240+
241+fn snap_to_end(name: &str) -> Task<Message> {
242+ iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
243+}
244+
138245 // --- the app -----------------------------------------------------------------
139246
140247 struct App {
141248 core: Core,
142249 tree: Arc<Tree>,
250+ scrolls: HashMap<String, ScrollMemo>,
143251 }
144252
145253 #[derive(Clone, Debug)]
@@ -150,6 +258,9 @@ enum Message {
150258 Toggled(i32, bool),
151259 Change(i32, String),
152260 Activate(i32),
261+ Hover(i32),
262+ Unhover(i32),
263+ Scrolled(i32, String, Viewport),
153264 }
154265
155266 impl App {
@@ -160,6 +271,67 @@ impl App {
160271 edit(|t| t.set(node, key, value.clone()));
161272 Arc::make_mut(&mut self.tree).set(node, key, value);
162273 }
274+
275+ /// Take the committed tree, and move every scroll area to where it should
276+ /// be now that it has changed.
277+ ///
278+ /// A snap is relative, so a list snapped to its end stays at its end as
279+ /// rows arrive under it, until the reader scrolls away.
280+ fn take_tree(&mut self) -> Task<Message> {
281+ let before = std::mem::replace(&mut self.tree, lock(&COMMITTED).clone());
282+ let mut tasks = Vec::new();
283+ let mut live = HashSet::new();
284+ for ask in scroll_asks(&before, &self.tree) {
285+ live.insert(ask.name.clone());
286+ let memo = self
287+ .scrolls
288+ .entry(ask.name.clone())
289+ .or_insert(ScrollMemo {
290+ at_end: ask.stick,
291+ offset_y: 0.0,
292+ });
293+ let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
294+ if let Some(fraction) = ask.reveal {
295+ memo.at_end = false;
296+ tasks.push(iced_scrollable::snap_to(
297+ scroll_id(&ask.name),
298+ RelativeOffset { x: None, y: Some(fraction) },
299+ ));
300+ } else if jumped || (ask.stick && memo.at_end) {
301+ memo.at_end = true;
302+ tasks.push(snap_to_end(&ask.name));
303+ } else if ask.fresh {
304+ tasks.push(iced_scrollable::scroll_to(
305+ scroll_id(&ask.name),
306+ AbsoluteOffset { x: None, y: Some(memo.offset_y) },
307+ ));
308+ }
309+ }
310+ // A list that was never scrolled keeps no memo worth the space; one
311+ // that was keeps its place for when it comes back.
312+ self.scrolls
313+ .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
314+ Task::batch(tasks)
315+ }
316+
317+ fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
318+ let y = viewport.absolute_offset().y;
319+ let room = viewport.content_bounds().height - viewport.bounds().height;
320+ let at_end = room - y <= AT_END_SLACK;
321+ let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
322+ at_end,
323+ offset_y: y,
324+ });
325+ let was = memo.at_end;
326+ memo.at_end = at_end;
327+ memo.offset_y = y;
328+ // "end" or "away", the strings libvidya emits: frq's handler compares
329+ // against "end".
330+ if was != at_end {
331+ let place = if at_end { "end" } else { "away" };
332+ post(node, "change", place.to_owned(), 0.0);
333+ }
334+ }
163335 }
164336
165337 impl cosmic::Application for App {
@@ -180,6 +352,7 @@ impl cosmic::Application for App {
180352 let mut app = App {
181353 core,
182354 tree: lock(&COMMITTED).clone(),
355+ scrolls: HashMap::new(),
183356 };
184357 // libcosmic's `wayland` feature brings `multi-window` with it, which
185358 // makes a window title a per-window thing.
@@ -197,7 +370,7 @@ impl cosmic::Application for App {
197370
198371 fn update(&mut self, message: Message) -> Task<Message> {
199372 match message {
200- Message::Tree => self.tree = lock(&COMMITTED).clone(),
373+ Message::Tree => return self.take_tree(),
201374 Message::Quit => return cosmic::iced::exit(),
202375 Message::Click(node) => post(node, "click", String::new(), 0.0),
203376 Message::Toggled(node, on) => {
@@ -209,49 +382,161 @@ impl cosmic::Application for App {
209382 post(node, "change", text, 0.0);
210383 }
211384 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
385+ Message::Hover(node) => post(node, "hover", String::new(), 0.0),
386+ Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
387+ Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
212388 }
213389 Task::none()
214390 }
215391
216392 fn view(&self) -> Element<'_, Message> {
217393 let tree = &*self.tree;
218- element(tree, tree.root_id(), true)
394+ element(tree, tree.root_id(), true, false)
395+ }
396+}
397+
398+// --- props into layout -----------------------------------------------------------
399+
400+/// `margin` all round, with `margin-top` and its siblings overriding a side.
401+fn margins(n: &Node) -> Padding {
402+ let all = n.num("margin").unwrap_or(0.0) as f32;
403+ let side = |key| n.num(key).map_or(all, |v| v as f32);
404+ Padding {
405+ top: side("margin-top"),
406+ right: side("margin-right"),
407+ bottom: side("margin-bottom"),
408+ left: side("margin-left"),
409+ }
410+}
411+
412+/// A width the client asked for. Zero is the client saying "none": frq writes
413+/// `:width-request 0` on its message column whenever the people panel is shut,
414+/// and taken literally that is a backlog laid out zero points wide.
415+fn width_request(n: &Node) -> Option<f32> {
416+ n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
417+}
418+
419+fn alignment(n: &Node) -> Alignment {
420+ match n.str("align") {
421+ "center" => Alignment::Center,
422+ "end" => Alignment::End,
423+ _ => Alignment::Start,
219424 }
220425 }
221426
222-/// One node and everything under it, as widgets. `enabled` is inherited: an
223-/// insensitive container takes its whole subtree out of interaction.
224-fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
427+fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
428+ cosmic::theme::Container::custom(move |_| ContainerStyle {
429+ background: Some(Background::Color(color)),
430+ border: Border {
431+ radius: radius.into(),
432+ ..Border::default()
433+ },
434+ text_color: Some(Color::WHITE),
435+ ..ContainerStyle::default()
436+ })
437+}
438+
439+/// A colour for somebody, from their name, so the same person is the same
440+/// colour everywhere they appear.
441+fn name_colour(name: &str) -> Color {
442+ const PALETTE: [(f32, f32, f32); 8] = [
443+ (0.83, 0.33, 0.33),
444+ (0.85, 0.55, 0.20),
445+ (0.62, 0.62, 0.18),
446+ (0.30, 0.65, 0.35),
447+ (0.20, 0.62, 0.62),
448+ (0.30, 0.50, 0.85),
449+ (0.55, 0.40, 0.85),
450+ (0.80, 0.35, 0.65),
451+ ];
452+ let hash = name
453+ .bytes()
454+ .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
455+ let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
456+ Color::from_rgb(r, g, b)
457+}
458+
459+/// A picture that answers a click, with the pointer saying so.
460+fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
461+ if !enabled {
462+ return el;
463+ }
464+ widget::mouse_area(el)
465+ .on_press(Message::Click(id))
466+ .interaction(cosmic::iced::mouse::Interaction::Pointer)
467+ .into()
468+}
469+
470+fn picture(path: &str) -> Option<widget::image::Handle> {
471+ (!path.is_empty() && std::path::Path::new(path).exists())
472+ .then(|| widget::image::Handle::from_path(path))
473+}
474+
475+// --- the tree into widgets ---------------------------------------------------------
476+
477+/// One node and everything under it, as widgets.
478+///
479+/// `enabled` is inherited: an insensitive container takes its whole subtree out
480+/// of interaction. `in_row` is whether the parent lays its children out across:
481+/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
482+/// column in a column takes the width and a column in a row does not take the
483+/// row's slack unless it says `fill-height`.
484+fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
225485 let Some(n) = t.get(id) else {
226486 return Column::new().into();
227487 };
228488 let enabled = enabled && n.bool("sensitive") != Some(false);
229- let spacing = n.num("spacing").unwrap_or(8.0) as f32;
230- let children = || n.children.iter().map(move |c| element(t, *c, enabled));
489+ let fill_height = n.bool("fill-height") == Some(true);
490+ let spacing = n.num("spacing").unwrap_or(0.0) as f32;
491+ let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row));
231492
232493 let el: Element<'_, Message> = match n.tag.as_str() {
233- "window" => Column::with_children(children())
494+ "window" => Column::with_children(children(false))
234495 .width(Length::Fill)
235496 .height(Length::Fill)
236497 .into(),
498+ // Sizes are set only where something asked for one. iced's rows and
499+ // columns take `Fill` on an axis from any child that fills it, which is
500+ // glimmer-jvui's `fills-height?` rule done for us — and an explicit
501+ // `Shrink` would throw that away, so a wrapper with no `fill-height` of
502+ // its own would hand the list inside it no height at all.
237503 "box" => {
238- let margin = n.num("margin").unwrap_or(0.0) as f32;
239- if n.str("orientation") == "horizontal" {
240- Row::with_children(children())
504+ let across = n.str("orientation") == "horizontal";
505+ if across {
506+ let mut row = Row::with_children(children(true))
241507 .spacing(spacing)
242- .padding(margin)
243- .align_y(Alignment::Center)
244- .into()
508+ .padding(margins(n))
509+ .align_y(alignment(n));
510+ // A row fills the width it is in only when it or something in
511+ // it asks to; otherwise a line of buttons would spread out.
512+ match width_request(n) {
513+ Some(w) => row = row.width(w),
514+ None if fill_height => row = row.width(Length::Fill),
515+ None => {}
516+ }
517+ if fill_height {
518+ row = row.height(Length::Fill);
519+ }
520+ row.into()
245521 } else {
246- Column::with_children(children())
522+ let mut column = Column::with_children(children(false))
247523 .spacing(spacing)
248- .padding(margin)
249- .into()
524+ .padding(margins(n))
525+ .align_x(alignment(n));
526+ match width_request(n) {
527+ Some(w) => column = column.width(w),
528+ None if fill_height || !in_row => column = column.width(Length::Fill),
529+ None => {}
530+ }
531+ if fill_height {
532+ column = column.height(Length::Fill);
533+ }
534+ column.into()
250535 }
251536 }
252537 "page" => {
253- let column = Column::with_children(children())
254- .spacing(spacing)
538+ let column = Column::with_children(children(false))
539+ .spacing(n.num("spacing").unwrap_or(8.0) as f32)
255540 .padding(24)
256541 .width(Length::Fill);
257542 let mut inner = widget::container(column).width(Length::Fill);
@@ -259,21 +544,42 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
259544 inner = inner.max_width(max as f32);
260545 }
261546 widget::scrollable(widget::container(inner).center_x(Length::Fill))
547+ .width(Length::Fill)
262548 .height(Length::Fill)
263549 .into()
264550 }
265551 "card" | "frame" => {
266- let mut column = Column::new().spacing(spacing);
552+ let mut column = Column::new().spacing(n.num("spacing").unwrap_or(8.0) as f32);
267553 if n.tag == "frame" && !n.label().is_empty() {
268554 column = column.push(widget::text::heading(n.label()));
269555 }
270- widget::container(column.extend(children()))
271- .padding(16)
556+ let card = widget::container(column.extend(children(false)))
557+ .padding(12)
558+ .class(cosmic::theme::Container::Card);
559+ match width_request(n) {
560+ Some(w) => card.width(w).into(),
561+ None if !in_row => card.width(Length::Fill).into(),
562+ None => card.into(),
563+ }
564+ }
565+ // Always fills both ways: a viewport that only fills its width asks its
566+ // column for no height, and is given none. The content is held to its
567+ // own height, since iced will not scroll content that fills the axis it
568+ // scrolls along.
569+ "scroll" => {
570+ let name = scroll_name(n, id);
571+ let content = Column::with_children(children(false))
572+ .spacing(spacing)
573+ .width(Length::Fill)
574+ .height(Length::Shrink);
575+ widget::scrollable(content)
576+ .id(scroll_id(&name))
272577 .width(Length::Fill)
273- .class(cosmic::theme::Container::Card)
578+ .height(Length::Fill)
579+ .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
274580 .into()
275581 }
276- "scroll" => widget::scrollable(Column::with_children(children()).spacing(spacing)).into(),
582+ "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()).into(),
277583 "label" => widget::text::body(n.label()).into(),
278584 "title" => widget::text::title3(n.label()).into(),
279585 "title-2" => widget::text::title4(n.label()).into(),
@@ -288,6 +594,142 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
288594 .on_press_maybe(enabled.then_some(Message::Click(id)))
289595 .into()
290596 }
597+ "link" => widget::button::link(n.label().to_owned())
598+ .on_press_maybe(enabled.then_some(Message::Click(id)))
599+ .into(),
600+ // A dot that says whether the thing is live, and the words beside it.
601+ "status" => {
602+ let colour = if n.bool("live") == Some(true) {
603+ Color::from_rgb(0.30, 0.72, 0.40)
604+ } else {
605+ Color::from_rgb(0.55, 0.55, 0.55)
606+ };
607+ let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
608+ Row::new()
609+ .spacing(6)
610+ .align_y(Alignment::Center)
611+ .push(dot)
612+ .push(widget::text::caption(n.label()))
613+ .into()
614+ }
615+ "spinner" => {
616+ let mut row = Row::new()
617+ .spacing(8)
618+ .align_y(Alignment::Center)
619+ .push(widget::progress_bar::indeterminate_circular().size(16.0));
620+ if !n.label().is_empty() {
621+ row = row.push(widget::text::caption(n.label()));
622+ }
623+ row.into()
624+ }
625+ "emoji" => {
626+ let glyph = match n.str("emoji") {
627+ "" => n.label(),
628+ e => e,
629+ };
630+ widget::text(glyph.to_owned())
631+ .size(n.num("size").unwrap_or(16.0) as f32)
632+ .into()
633+ }
634+ // A round picture, or the initial on a colour from the name: most
635+ // people in most rooms have no picture, so the initial IS the avatar.
636+ "avatar" => {
637+ let size = n.num("size").unwrap_or(32.0) as f32;
638+ match picture(n.str("src")) {
639+ Some(handle) => widget::image(handle)
640+ .width(size)
641+ .height(size)
642+ .content_fit(ContentFit::Cover)
643+ .border_radius(size / 2.0)
644+ .into(),
645+ None => {
646+ let initial: String = n
647+ .label()
648+ .trim_start_matches(|c: char| !c.is_alphanumeric())
649+ .chars()
650+ .next()
651+ .map(|c| c.to_uppercase().collect())
652+ .unwrap_or_default();
653+ widget::container(widget::text(initial).size(size * 0.45))
654+ .center(Length::Fixed(size))
655+ .class(filled(name_colour(n.label()), size / 2.0))
656+ .into()
657+ }
658+ }
659+ }
660+ // A pill: an emoji, how many people, and whether you are one of them.
661+ // What the client hangs under it is its hover card, shown while the
662+ // pointer is on the pill.
663+ "reaction" => {
664+ let glyph = match n.str("emoji") {
665+ "" => n.label(),
666+ e => e,
667+ };
668+ let size = n.num("size").unwrap_or(16.0) as f32;
669+ let mut content = Row::new()
670+ .spacing(4)
671+ .align_y(Alignment::Center)
672+ .push(widget::text(glyph.to_owned()).size(size));
673+ let count = n.num("count").unwrap_or(0.0);
674+ if count > 0.0 {
675+ content = content.push(widget::text::caption(format!("{count}")));
676+ }
677+ let class = if n.bool("mine") == Some(true) {
678+ widget::button::ButtonClass::Suggested
679+ } else {
680+ widget::button::ButtonClass::Standard
681+ };
682+ let pill = widget::button::custom(content)
683+ .padding([2, 8])
684+ .class(class)
685+ .on_press_maybe(enabled.then_some(Message::Click(id)));
686+ let pill = widget::mouse_area(pill)
687+ .on_enter(Message::Hover(id))
688+ .on_exit(Message::Unhover(id));
689+ if n.children.is_empty() {
690+ pill.into()
691+ } else {
692+ widget::tooltip(
693+ pill,
694+ Column::with_children(children(false)).spacing(4),
695+ widget::tooltip::Position::Bottom,
696+ )
697+ .into()
698+ }
699+ }
700+ // One tag for both kinds of picture, as in libvidya. `feed` is live
701+ // pixels pushed under a name, which nothing pushes here yet, so it
702+ // holds the slot the layout gave it.
703+ "image" => {
704+ let max_w = n.num("max-width").map(|v| v as f32);
705+ let max_h = n.num("max-height").map(|v| v as f32);
706+ if !n.str("feed").is_empty() {
707+ let w = max_w.unwrap_or(160.0);
708+ let h = max_h.unwrap_or(w * 0.75);
709+ widget::container(widget::text::caption("video"))
710+ .center_x(Length::Fixed(w))
711+ .center_y(Length::Fixed(h))
712+ .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
713+ .into()
714+ } else if let Some(handle) = picture(n.str("src")) {
715+ let mut image = widget::image(handle).content_fit(ContentFit::Contain);
716+ if n.bool("fit") == Some(true) {
717+ image = image.width(Length::Fill).height(Length::Fill);
718+ } else if let Some(size) = n.num("size") {
719+ image = image.width(size as f32).height(size as f32);
720+ }
721+ let mut bounded = widget::container(image);
722+ if let Some(w) = max_w {
723+ bounded = bounded.max_width(w);
724+ }
725+ if let Some(h) = max_h {
726+ bounded = bounded.max_height(h);
727+ }
728+ clickable(bounded.into(), id, enabled)
729+ } else {
730+ widget::Space::new().width(0).height(0).into()
731+ }
732+ }
291733 "checkbutton" => {
292734 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
293735 if enabled {
@@ -302,12 +744,20 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
302744 .on_input(move |text| Message::Change(id, text))
303745 .on_submit(move |_| Message::Activate(id));
304746 }
305- entry.into()
747+ let width = match width_request(n) {
748+ Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
749+ _ => Length::Fill,
750+ };
751+ entry.width(width).into()
306752 }
307753 "separator" => widget::divider::horizontal::default().into(),
308754 "spacer" => {
309755 let size = n.num("size").unwrap_or(8.0) as f32;
310- widget::Space::new().width(size).height(size).into()
756+ if n.str("expand").is_empty() {
757+ widget::Space::new().width(size).height(size).into()
758+ } else {
759+ widget::Space::new().width(Length::Fill).height(size).into()
760+ }
311761 }
312762 "progress" => {
313763 let bar =
@@ -324,12 +774,17 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
324774 }
325775 // Kept rather than refused, as in libvidya: a tag this backend has not
326776 // grown yet still shows its children.
327- _ => Column::with_children(children()).spacing(spacing).into(),
777+ _ => Column::with_children(children(false))
778+ .spacing(spacing)
779+ .padding(margins(n))
780+ .into(),
328781 };
329782
330- match n.num("width-request") {
331- Some(width) => widget::container(el).width(width as f32).into(),
332- None => el,
783+ // The containers and the entry size themselves above; anything else asked
784+ // for a width gets it from a wrapper.
785+ match (n.tag.as_str(), width_request(n)) {
786+ ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
787+ (_, Some(width)) => widget::container(el).width(width).into(),
333788 }
334789 }
335790
@@ -611,3 +1066,66 @@ pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
6111066 }))
6121067 })
6131068 }
1069+
1070+#[cfg(test)]
1071+mod tests {
1072+ use super::*;
1073+
1074+ fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1075+ let id = t.new_node(tag);
1076+ assert!(t.append(parent, id));
1077+ id
1078+ }
1079+
1080+ #[test]
1081+ fn a_zero_width_request_is_no_request() {
1082+ let mut t = Tree::default();
1083+ let root = t.root();
1084+ let column = node(&mut t, root, "vbox");
1085+ t.set(column, "width-request", Prop::Num(0.0));
1086+ assert_eq!(width_request(t.get(column).unwrap()), None);
1087+ t.set(column, "width-request", Prop::Num(260.0));
1088+ assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1089+ }
1090+
1091+ #[test]
1092+ fn a_scroll_is_named_by_its_scroll_key() {
1093+ let mut t = Tree::default();
1094+ let root = t.root();
1095+ let list = node(&mut t, root, "scroll");
1096+ assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1097+ t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1098+ assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1099+ }
1100+
1101+ #[test]
1102+ fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {
1103+ let mut t = Tree::default();
1104+ let root = t.root();
1105+ let list = node(&mut t, root, "scroll");
1106+ t.set(list, "scroll-key", Prop::Str("backlog".into()));
1107+ let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1108+ let before = t.clone();
1109+ t.set(rows[3], "scroll-here", Prop::Bool(true));
1110+
1111+ let asks = scroll_asks(&before, &t);
1112+ assert_eq!(asks.len(), 1);
1113+ assert!(!asks[0].fresh);
1114+ assert_eq!(asks[0].reveal, Some(0.75));
1115+
1116+ let again = scroll_asks(&t, &t);
1117+ assert_eq!(again[0].reveal, None);
1118+ }
1119+
1120+ #[test]
1121+ fn a_jump_is_the_counter_moving() {
1122+ let mut t = Tree::default();
1123+ let root = t.root();
1124+ let list = node(&mut t, root, "scroll");
1125+ t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1126+ let before = t.clone();
1127+ t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1128+ let asks = scroll_asks(&before, &t);
1129+ assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1130+ }
1131+}
@@ -23,7 +23,7 @@ mod tree;
23 23
24 pub use tree::{Node, Prop, Tree};24 pub use tree::{Node, Prop, Tree};
25 25
26-use std::collections::VecDeque;26+use std::collections::{HashMap, HashSet, VecDeque};
27 use std::ffi::{c_char, c_int};27 use std::ffi::{c_char, c_int};
28 use std::sync::atomic::{AtomicBool, Ordering::SeqCst};28 use std::sync::atomic::{AtomicBool, Ordering::SeqCst};
29 use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};29 use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard};
@@ -32,7 +32,9 @@ use std::time::Duration;
32 use cosmic::app::{Core, Task};32 use cosmic::app::{Core, Task};
33 use cosmic::iced::futures::channel::mpsc;33 use cosmic::iced::futures::channel::mpsc;
34 use cosmic::iced::futures::{Stream, StreamExt};34 use cosmic::iced::futures::{Stream, StreamExt};
35-use cosmic::iced::{Alignment, Length, Subscription};35+use cosmic::iced::widget::container::Style as ContainerStyle;
36+use cosmic::iced::widget::scrollable::{self as iced_scrollable, AbsoluteOffset, RelativeOffset, Viewport};
37+use cosmic::iced::{Alignment, Background, Border, Color, ContentFit, Length, Padding, Subscription};
36 use cosmic::widget::{self, Column, Row};38 use cosmic::widget::{self, Column, Row};
37 use cosmic::{ApplicationExt, Element};39 use cosmic::{ApplicationExt, Element};
38 use jolt_abi::{borrowed, empty_str, guard, Scratch};40 use jolt_abi::{borrowed, empty_str, guard, Scratch};
@@ -135,11 +137,117 @@ fn wakes() -> impl Stream<Item = Message> {
135 })137 })
136 }138 }
137 139
140+// --- scroll areas ------------------------------------------------------------
141+
142+/// Where a scroll area was left, kept by name rather than on the widget.
143+///
144+/// iced keeps a scrollable's offset in its widget tree, and a widget that is
145+/// unmounted and mounted again starts at the top. glimmer clients unmount
146+/// lists all the time — frq's lightbox is a screen, so looking at a picture
147+/// takes the backlog away — so the place is remembered here, under the
148+/// `scroll-key` the client names the list by, and put back when it returns.
149+struct ScrollMemo {
150+ /// Whether the reader is at the newest line. A `stick-to-bottom` list
151+ /// follows what arrives only while this holds.
152+ at_end: bool,
153+ offset_y: f32,
154+}
155+
156+/// Two points of slack: a viewport scrolled to its end by a fractional
157+/// offset is still at the end.
158+const AT_END_SLACK: f32 = 2.0;
159+
160+fn scroll_name(n: &Node, id: i32) -> String {
161+ match n.str("scroll-key") {
162+ "" => format!("node-{id}"),
163+ key => key.to_owned(),
164+ }
165+}
166+
167+fn scroll_id(name: &str) -> widget::Id {
168+ widget::Id::new(format!("jolt-scroll-{name}"))
169+}
170+
171+fn walk<'t>(t: &'t Tree, id: i32, f: &mut impl FnMut(i32, &'t Node)) {
172+ if let Some(n) = t.get(id) {
173+ f(id, n);
174+ for child in &n.children {
175+ walk(t, *child, f);
176+ }
177+ }
178+}
179+
180+/// What a commit asks of one scroll area.
181+struct ScrollAsk {
182+ name: String,
183+ stick: bool,
184+ /// The `scroll-to-bottom` counter, and what it was in the tree before.
185+ tick: Option<f64>,
186+ tick_before: Option<f64>,
187+ /// Not in the tree before this commit: mounted, or mounted again.
188+ fresh: bool,
189+ /// Where, as a fraction of the list, a node that has just been asked to be
190+ /// shown sits.
191+ reveal: Option<f32>,
192+}
193+
194+/// Every scroll area in `now`, and what changed about each since `before`.
195+fn scroll_asks(before: &Tree, now: &Tree) -> Vec<ScrollAsk> {
196+ let mut named_before: HashMap<String, Option<f64>> = HashMap::new();
197+ walk(before, before.root_id(), &mut |id, n| {
198+ if n.tag == "scroll" {
199+ named_before.insert(scroll_name(n, id), n.num("scroll-to-bottom"));
200+ }
201+ });
202+
203+ let mut asks = Vec::new();
204+ walk(now, now.root_id(), &mut |id, n| {
205+ if n.tag != "scroll" {
206+ return;
207+ }
208+ let name = scroll_name(n, id);
209+ // A row asking to be shown, that was not asking last commit. The
210+ // position is the index of the top-level row holding it: exact for a
211+ // list of rows the same height and close for frq's backlog, and there
212+ // is no layout to ask from here.
213+ let count = n.children.len();
214+ let mut reveal = None;
215+ for (index, row) in n.children.iter().enumerate() {
216+ let mut asked = false;
217+ walk(now, *row, &mut |nid, node| {
218+ let here = node.bool("scroll-here") == Some(true);
219+ let was = before
220+ .get(nid)
221+ .is_some_and(|old| old.bool("scroll-here") == Some(true));
222+ asked |= here && !was;
223+ });
224+ if asked {
225+ reveal = Some(index as f32 / (count.saturating_sub(1).max(1)) as f32);
226+ break;
227+ }
228+ }
229+ asks.push(ScrollAsk {
230+ fresh: !named_before.contains_key(&name),
231+ tick_before: named_before.get(&name).copied().flatten(),
232+ tick: n.num("scroll-to-bottom"),
233+ stick: n.bool("stick-to-bottom") == Some(true),
234+ reveal,
235+ name,
236+ });
237+ });
238+ asks
239+}
240+
241+fn snap_to_end(name: &str) -> Task<Message> {
242+ iced_scrollable::snap_to(scroll_id(name), RelativeOffset { x: None, y: Some(1.0) })
243+}
244+
138 // --- the app -----------------------------------------------------------------245 // --- the app -----------------------------------------------------------------
139 246
140 struct App {247 struct App {
141 core: Core,248 core: Core,
142 tree: Arc<Tree>,249 tree: Arc<Tree>,
250+ scrolls: HashMap<String, ScrollMemo>,
143 }251 }
144 252
145 #[derive(Clone, Debug)]253 #[derive(Clone, Debug)]
@@ -150,6 +258,9 @@ enum Message {
150 Toggled(i32, bool),258 Toggled(i32, bool),
151 Change(i32, String),259 Change(i32, String),
152 Activate(i32),260 Activate(i32),
261+ Hover(i32),
262+ Unhover(i32),
263+ Scrolled(i32, String, Viewport),
153 }264 }
154 265
155 impl App {266 impl App {
@@ -160,6 +271,67 @@ impl App {
160 edit(|t| t.set(node, key, value.clone()));271 edit(|t| t.set(node, key, value.clone()));
161 Arc::make_mut(&mut self.tree).set(node, key, value);272 Arc::make_mut(&mut self.tree).set(node, key, value);
162 }273 }
274+
275+ /// Take the committed tree, and move every scroll area to where it should
276+ /// be now that it has changed.
277+ ///
278+ /// A snap is relative, so a list snapped to its end stays at its end as
279+ /// rows arrive under it, until the reader scrolls away.
280+ fn take_tree(&mut self) -> Task<Message> {
281+ let before = std::mem::replace(&mut self.tree, lock(&COMMITTED).clone());
282+ let mut tasks = Vec::new();
283+ let mut live = HashSet::new();
284+ for ask in scroll_asks(&before, &self.tree) {
285+ live.insert(ask.name.clone());
286+ let memo = self
287+ .scrolls
288+ .entry(ask.name.clone())
289+ .or_insert(ScrollMemo {
290+ at_end: ask.stick,
291+ offset_y: 0.0,
292+ });
293+ let jumped = !ask.fresh && ask.tick.is_some() && ask.tick != ask.tick_before;
294+ if let Some(fraction) = ask.reveal {
295+ memo.at_end = false;
296+ tasks.push(iced_scrollable::snap_to(
297+ scroll_id(&ask.name),
298+ RelativeOffset { x: None, y: Some(fraction) },
299+ ));
300+ } else if jumped || (ask.stick && memo.at_end) {
301+ memo.at_end = true;
302+ tasks.push(snap_to_end(&ask.name));
303+ } else if ask.fresh {
304+ tasks.push(iced_scrollable::scroll_to(
305+ scroll_id(&ask.name),
306+ AbsoluteOffset { x: None, y: Some(memo.offset_y) },
307+ ));
308+ }
309+ }
310+ // A list that was never scrolled keeps no memo worth the space; one
311+ // that was keeps its place for when it comes back.
312+ self.scrolls
313+ .retain(|name, memo| live.contains(name) || !memo.at_end || memo.offset_y > 0.0);
314+ Task::batch(tasks)
315+ }
316+
317+ fn scrolled(&mut self, node: i32, name: String, viewport: Viewport) {
318+ let y = viewport.absolute_offset().y;
319+ let room = viewport.content_bounds().height - viewport.bounds().height;
320+ let at_end = room - y <= AT_END_SLACK;
321+ let memo = self.scrolls.entry(name).or_insert(ScrollMemo {
322+ at_end,
323+ offset_y: y,
324+ });
325+ let was = memo.at_end;
326+ memo.at_end = at_end;
327+ memo.offset_y = y;
328+ // "end" or "away", the strings libvidya emits: frq's handler compares
329+ // against "end".
330+ if was != at_end {
331+ let place = if at_end { "end" } else { "away" };
332+ post(node, "change", place.to_owned(), 0.0);
333+ }
334+ }
163 }335 }
164 336
165 impl cosmic::Application for App {337 impl cosmic::Application for App {
@@ -180,6 +352,7 @@ impl cosmic::Application for App {
180 let mut app = App {352 let mut app = App {
181 core,353 core,
182 tree: lock(&COMMITTED).clone(),354 tree: lock(&COMMITTED).clone(),
355+ scrolls: HashMap::new(),
183 };356 };
184 // libcosmic's `wayland` feature brings `multi-window` with it, which357 // libcosmic's `wayland` feature brings `multi-window` with it, which
185 // makes a window title a per-window thing.358 // makes a window title a per-window thing.
@@ -197,7 +370,7 @@ impl cosmic::Application for App {
197 370
198 fn update(&mut self, message: Message) -> Task<Message> {371 fn update(&mut self, message: Message) -> Task<Message> {
199 match message {372 match message {
200- Message::Tree => self.tree = lock(&COMMITTED).clone(),373+ Message::Tree => return self.take_tree(),
201 Message::Quit => return cosmic::iced::exit(),374 Message::Quit => return cosmic::iced::exit(),
202 Message::Click(node) => post(node, "click", String::new(), 0.0),375 Message::Click(node) => post(node, "click", String::new(), 0.0),
203 Message::Toggled(node, on) => {376 Message::Toggled(node, on) => {
@@ -209,49 +382,161 @@ impl cosmic::Application for App {
209 post(node, "change", text, 0.0);382 post(node, "change", text, 0.0);
210 }383 }
211 Message::Activate(node) => post(node, "activate", String::new(), 0.0),384 Message::Activate(node) => post(node, "activate", String::new(), 0.0),
385+ Message::Hover(node) => post(node, "hover", String::new(), 0.0),
386+ Message::Unhover(node) => post(node, "unhover", String::new(), 0.0),
387+ Message::Scrolled(node, name, viewport) => self.scrolled(node, name, viewport),
212 }388 }
213 Task::none()389 Task::none()
214 }390 }
215 391
216 fn view(&self) -> Element<'_, Message> {392 fn view(&self) -> Element<'_, Message> {
217 let tree = &*self.tree;393 let tree = &*self.tree;
218- element(tree, tree.root_id(), true)394+ element(tree, tree.root_id(), true, false)
395+ }
396+}
397+
398+// --- props into layout -----------------------------------------------------------
399+
400+/// `margin` all round, with `margin-top` and its siblings overriding a side.
401+fn margins(n: &Node) -> Padding {
402+ let all = n.num("margin").unwrap_or(0.0) as f32;
403+ let side = |key| n.num(key).map_or(all, |v| v as f32);
404+ Padding {
405+ top: side("margin-top"),
406+ right: side("margin-right"),
407+ bottom: side("margin-bottom"),
408+ left: side("margin-left"),
409+ }
410+}
411+
412+/// A width the client asked for. Zero is the client saying "none": frq writes
413+/// `:width-request 0` on its message column whenever the people panel is shut,
414+/// and taken literally that is a backlog laid out zero points wide.
415+fn width_request(n: &Node) -> Option<f32> {
416+ n.num("width-request").filter(|w| *w > 0.0).map(|w| w as f32)
417+}
418+
419+fn alignment(n: &Node) -> Alignment {
420+ match n.str("align") {
421+ "center" => Alignment::Center,
422+ "end" => Alignment::End,
423+ _ => Alignment::Start,
219 }424 }
220 }425 }
221 426
222-/// One node and everything under it, as widgets. `enabled` is inherited: an427+fn filled(color: Color, radius: f32) -> cosmic::theme::Container<'static> {
223-/// insensitive container takes its whole subtree out of interaction.428+ cosmic::theme::Container::custom(move |_| ContainerStyle {
224-fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {429+ background: Some(Background::Color(color)),
430+ border: Border {
431+ radius: radius.into(),
432+ ..Border::default()
433+ },
434+ text_color: Some(Color::WHITE),
435+ ..ContainerStyle::default()
436+ })
437+}
438+
439+/// A colour for somebody, from their name, so the same person is the same
440+/// colour everywhere they appear.
441+fn name_colour(name: &str) -> Color {
442+ const PALETTE: [(f32, f32, f32); 8] = [
443+ (0.83, 0.33, 0.33),
444+ (0.85, 0.55, 0.20),
445+ (0.62, 0.62, 0.18),
446+ (0.30, 0.65, 0.35),
447+ (0.20, 0.62, 0.62),
448+ (0.30, 0.50, 0.85),
449+ (0.55, 0.40, 0.85),
450+ (0.80, 0.35, 0.65),
451+ ];
452+ let hash = name
453+ .bytes()
454+ .fold(0u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
455+ let (r, g, b) = PALETTE[hash as usize % PALETTE.len()];
456+ Color::from_rgb(r, g, b)
457+}
458+
459+/// A picture that answers a click, with the pointer saying so.
460+fn clickable(el: Element<'_, Message>, id: i32, enabled: bool) -> Element<'_, Message> {
461+ if !enabled {
462+ return el;
463+ }
464+ widget::mouse_area(el)
465+ .on_press(Message::Click(id))
466+ .interaction(cosmic::iced::mouse::Interaction::Pointer)
467+ .into()
468+}
469+
470+fn picture(path: &str) -> Option<widget::image::Handle> {
471+ (!path.is_empty() && std::path::Path::new(path).exists())
472+ .then(|| widget::image::Handle::from_path(path))
473+}
474+
475+// --- the tree into widgets ---------------------------------------------------------
476+
477+/// One node and everything under it, as widgets.
478+///
479+/// `enabled` is inherited: an insensitive container takes its whole subtree out
480+/// of interaction. `in_row` is whether the parent lays its children out across:
481+/// a container fills its parent's CROSS axis, as it does in glimmer-jvui, so a
482+/// column in a column takes the width and a column in a row does not take the
483+/// row's slack unless it says `fill-height`.
484+fn element(t: &Tree, id: i32, enabled: bool, in_row: bool) -> Element<'_, Message> {
225 let Some(n) = t.get(id) else {485 let Some(n) = t.get(id) else {
226 return Column::new().into();486 return Column::new().into();
227 };487 };
228 let enabled = enabled && n.bool("sensitive") != Some(false);488 let enabled = enabled && n.bool("sensitive") != Some(false);
229- let spacing = n.num("spacing").unwrap_or(8.0) as f32;489+ let fill_height = n.bool("fill-height") == Some(true);
230- let children = || n.children.iter().map(move |c| element(t, *c, enabled));490+ let spacing = n.num("spacing").unwrap_or(0.0) as f32;
491+ let children = |row: bool| n.children.iter().map(move |c| element(t, *c, enabled, row));
231 492
232 let el: Element<'_, Message> = match n.tag.as_str() {493 let el: Element<'_, Message> = match n.tag.as_str() {
233- "window" => Column::with_children(children())494+ "window" => Column::with_children(children(false))
234 .width(Length::Fill)495 .width(Length::Fill)
235 .height(Length::Fill)496 .height(Length::Fill)
236 .into(),497 .into(),
498+ // Sizes are set only where something asked for one. iced's rows and
499+ // columns take `Fill` on an axis from any child that fills it, which is
500+ // glimmer-jvui's `fills-height?` rule done for us — and an explicit
501+ // `Shrink` would throw that away, so a wrapper with no `fill-height` of
502+ // its own would hand the list inside it no height at all.
237 "box" => {503 "box" => {
238- let margin = n.num("margin").unwrap_or(0.0) as f32;504+ let across = n.str("orientation") == "horizontal";
239- if n.str("orientation") == "horizontal" {505+ if across {
240- Row::with_children(children())506+ let mut row = Row::with_children(children(true))
241 .spacing(spacing)507 .spacing(spacing)
242- .padding(margin)508+ .padding(margins(n))
243- .align_y(Alignment::Center)509+ .align_y(alignment(n));
244- .into()510+ // A row fills the width it is in only when it or something in
511+ // it asks to; otherwise a line of buttons would spread out.
512+ match width_request(n) {
513+ Some(w) => row = row.width(w),
514+ None if fill_height => row = row.width(Length::Fill),
515+ None => {}
516+ }
517+ if fill_height {
518+ row = row.height(Length::Fill);
519+ }
520+ row.into()
245 } else {521 } else {
246- Column::with_children(children())522+ let mut column = Column::with_children(children(false))
247 .spacing(spacing)523 .spacing(spacing)
248- .padding(margin)524+ .padding(margins(n))
249- .into()525+ .align_x(alignment(n));
526+ match width_request(n) {
527+ Some(w) => column = column.width(w),
528+ None if fill_height || !in_row => column = column.width(Length::Fill),
529+ None => {}
530+ }
531+ if fill_height {
532+ column = column.height(Length::Fill);
533+ }
534+ column.into()
250 }535 }
251 }536 }
252 "page" => {537 "page" => {
253- let column = Column::with_children(children())538+ let column = Column::with_children(children(false))
254- .spacing(spacing)539+ .spacing(n.num("spacing").unwrap_or(8.0) as f32)
255 .padding(24)540 .padding(24)
256 .width(Length::Fill);541 .width(Length::Fill);
257 let mut inner = widget::container(column).width(Length::Fill);542 let mut inner = widget::container(column).width(Length::Fill);
@@ -259,21 +544,42 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
259 inner = inner.max_width(max as f32);544 inner = inner.max_width(max as f32);
260 }545 }
261 widget::scrollable(widget::container(inner).center_x(Length::Fill))546 widget::scrollable(widget::container(inner).center_x(Length::Fill))
547+ .width(Length::Fill)
262 .height(Length::Fill)548 .height(Length::Fill)
263 .into()549 .into()
264 }550 }
265 "card" | "frame" => {551 "card" | "frame" => {
266- let mut column = Column::new().spacing(spacing);552+ let mut column = Column::new().spacing(n.num("spacing").unwrap_or(8.0) as f32);
267 if n.tag == "frame" && !n.label().is_empty() {553 if n.tag == "frame" && !n.label().is_empty() {
268 column = column.push(widget::text::heading(n.label()));554 column = column.push(widget::text::heading(n.label()));
269 }555 }
270- widget::container(column.extend(children()))556+ let card = widget::container(column.extend(children(false)))
271- .padding(16)557+ .padding(12)
558+ .class(cosmic::theme::Container::Card);
559+ match width_request(n) {
560+ Some(w) => card.width(w).into(),
561+ None if !in_row => card.width(Length::Fill).into(),
562+ None => card.into(),
563+ }
564+ }
565+ // Always fills both ways: a viewport that only fills its width asks its
566+ // column for no height, and is given none. The content is held to its
567+ // own height, since iced will not scroll content that fills the axis it
568+ // scrolls along.
569+ "scroll" => {
570+ let name = scroll_name(n, id);
571+ let content = Column::with_children(children(false))
572+ .spacing(spacing)
573+ .width(Length::Fill)
574+ .height(Length::Shrink);
575+ widget::scrollable(content)
576+ .id(scroll_id(&name))
272 .width(Length::Fill)577 .width(Length::Fill)
273- .class(cosmic::theme::Container::Card)578+ .height(Length::Fill)
579+ .on_scroll(move |viewport| Message::Scrolled(id, name.clone(), viewport))
274 .into()580 .into()
275 }581 }
276- "scroll" => widget::scrollable(Column::with_children(children()).spacing(spacing)).into(),582+ "label" if n.bool("dim") == Some(true) => widget::text::caption(n.label()).into(),
277 "label" => widget::text::body(n.label()).into(),583 "label" => widget::text::body(n.label()).into(),
278 "title" => widget::text::title3(n.label()).into(),584 "title" => widget::text::title3(n.label()).into(),
279 "title-2" => widget::text::title4(n.label()).into(),585 "title-2" => widget::text::title4(n.label()).into(),
@@ -288,6 +594,142 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
288 .on_press_maybe(enabled.then_some(Message::Click(id)))594 .on_press_maybe(enabled.then_some(Message::Click(id)))
289 .into()595 .into()
290 }596 }
597+ "link" => widget::button::link(n.label().to_owned())
598+ .on_press_maybe(enabled.then_some(Message::Click(id)))
599+ .into(),
600+ // A dot that says whether the thing is live, and the words beside it.
601+ "status" => {
602+ let colour = if n.bool("live") == Some(true) {
603+ Color::from_rgb(0.30, 0.72, 0.40)
604+ } else {
605+ Color::from_rgb(0.55, 0.55, 0.55)
606+ };
607+ let dot = widget::container(widget::Space::new().width(8).height(8)).class(filled(colour, 4.0));
608+ Row::new()
609+ .spacing(6)
610+ .align_y(Alignment::Center)
611+ .push(dot)
612+ .push(widget::text::caption(n.label()))
613+ .into()
614+ }
615+ "spinner" => {
616+ let mut row = Row::new()
617+ .spacing(8)
618+ .align_y(Alignment::Center)
619+ .push(widget::progress_bar::indeterminate_circular().size(16.0));
620+ if !n.label().is_empty() {
621+ row = row.push(widget::text::caption(n.label()));
622+ }
623+ row.into()
624+ }
625+ "emoji" => {
626+ let glyph = match n.str("emoji") {
627+ "" => n.label(),
628+ e => e,
629+ };
630+ widget::text(glyph.to_owned())
631+ .size(n.num("size").unwrap_or(16.0) as f32)
632+ .into()
633+ }
634+ // A round picture, or the initial on a colour from the name: most
635+ // people in most rooms have no picture, so the initial IS the avatar.
636+ "avatar" => {
637+ let size = n.num("size").unwrap_or(32.0) as f32;
638+ match picture(n.str("src")) {
639+ Some(handle) => widget::image(handle)
640+ .width(size)
641+ .height(size)
642+ .content_fit(ContentFit::Cover)
643+ .border_radius(size / 2.0)
644+ .into(),
645+ None => {
646+ let initial: String = n
647+ .label()
648+ .trim_start_matches(|c: char| !c.is_alphanumeric())
649+ .chars()
650+ .next()
651+ .map(|c| c.to_uppercase().collect())
652+ .unwrap_or_default();
653+ widget::container(widget::text(initial).size(size * 0.45))
654+ .center(Length::Fixed(size))
655+ .class(filled(name_colour(n.label()), size / 2.0))
656+ .into()
657+ }
658+ }
659+ }
660+ // A pill: an emoji, how many people, and whether you are one of them.
661+ // What the client hangs under it is its hover card, shown while the
662+ // pointer is on the pill.
663+ "reaction" => {
664+ let glyph = match n.str("emoji") {
665+ "" => n.label(),
666+ e => e,
667+ };
668+ let size = n.num("size").unwrap_or(16.0) as f32;
669+ let mut content = Row::new()
670+ .spacing(4)
671+ .align_y(Alignment::Center)
672+ .push(widget::text(glyph.to_owned()).size(size));
673+ let count = n.num("count").unwrap_or(0.0);
674+ if count > 0.0 {
675+ content = content.push(widget::text::caption(format!("{count}")));
676+ }
677+ let class = if n.bool("mine") == Some(true) {
678+ widget::button::ButtonClass::Suggested
679+ } else {
680+ widget::button::ButtonClass::Standard
681+ };
682+ let pill = widget::button::custom(content)
683+ .padding([2, 8])
684+ .class(class)
685+ .on_press_maybe(enabled.then_some(Message::Click(id)));
686+ let pill = widget::mouse_area(pill)
687+ .on_enter(Message::Hover(id))
688+ .on_exit(Message::Unhover(id));
689+ if n.children.is_empty() {
690+ pill.into()
691+ } else {
692+ widget::tooltip(
693+ pill,
694+ Column::with_children(children(false)).spacing(4),
695+ widget::tooltip::Position::Bottom,
696+ )
697+ .into()
698+ }
699+ }
700+ // One tag for both kinds of picture, as in libvidya. `feed` is live
701+ // pixels pushed under a name, which nothing pushes here yet, so it
702+ // holds the slot the layout gave it.
703+ "image" => {
704+ let max_w = n.num("max-width").map(|v| v as f32);
705+ let max_h = n.num("max-height").map(|v| v as f32);
706+ if !n.str("feed").is_empty() {
707+ let w = max_w.unwrap_or(160.0);
708+ let h = max_h.unwrap_or(w * 0.75);
709+ widget::container(widget::text::caption("video"))
710+ .center_x(Length::Fixed(w))
711+ .center_y(Length::Fixed(h))
712+ .class(filled(Color::from_rgb(0.12, 0.12, 0.14), 8.0))
713+ .into()
714+ } else if let Some(handle) = picture(n.str("src")) {
715+ let mut image = widget::image(handle).content_fit(ContentFit::Contain);
716+ if n.bool("fit") == Some(true) {
717+ image = image.width(Length::Fill).height(Length::Fill);
718+ } else if let Some(size) = n.num("size") {
719+ image = image.width(size as f32).height(size as f32);
720+ }
721+ let mut bounded = widget::container(image);
722+ if let Some(w) = max_w {
723+ bounded = bounded.max_width(w);
724+ }
725+ if let Some(h) = max_h {
726+ bounded = bounded.max_height(h);
727+ }
728+ clickable(bounded.into(), id, enabled)
729+ } else {
730+ widget::Space::new().width(0).height(0).into()
731+ }
732+ }
291 "checkbutton" => {733 "checkbutton" => {
292 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());734 let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label());
293 if enabled {735 if enabled {
@@ -302,12 +744,20 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
302 .on_input(move |text| Message::Change(id, text))744 .on_input(move |text| Message::Change(id, text))
303 .on_submit(move |_| Message::Activate(id));745 .on_submit(move |_| Message::Activate(id));
304 }746 }
305- entry.into()747+ let width = match width_request(n) {
748+ Some(w) if n.bool("hexpand") != Some(true) => Length::Fixed(w),
749+ _ => Length::Fill,
750+ };
751+ entry.width(width).into()
306 }752 }
307 "separator" => widget::divider::horizontal::default().into(),753 "separator" => widget::divider::horizontal::default().into(),
308 "spacer" => {754 "spacer" => {
309 let size = n.num("size").unwrap_or(8.0) as f32;755 let size = n.num("size").unwrap_or(8.0) as f32;
310- widget::Space::new().width(size).height(size).into()756+ if n.str("expand").is_empty() {
757+ widget::Space::new().width(size).height(size).into()
758+ } else {
759+ widget::Space::new().width(Length::Fill).height(size).into()
760+ }
311 }761 }
312 "progress" => {762 "progress" => {
313 let bar =763 let bar =
@@ -324,12 +774,17 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> {
324 }774 }
325 // Kept rather than refused, as in libvidya: a tag this backend has not775 // Kept rather than refused, as in libvidya: a tag this backend has not
326 // grown yet still shows its children.776 // grown yet still shows its children.
327- _ => Column::with_children(children()).spacing(spacing).into(),777+ _ => Column::with_children(children(false))
778+ .spacing(spacing)
779+ .padding(margins(n))
780+ .into(),
328 };781 };
329 782
330- match n.num("width-request") {783+ // The containers and the entry size themselves above; anything else asked
331- Some(width) => widget::container(el).width(width as f32).into(),784+ // for a width gets it from a wrapper.
332- None => el,785+ match (n.tag.as_str(), width_request(n)) {
786+ ("box" | "card" | "frame" | "entry" | "scroll" | "page" | "window", _) | (_, None) => el,
787+ (_, Some(width)) => widget::container(el).width(width).into(),
333 }788 }
334 }789 }
335 790
@@ -611,3 +1066,66 @@ pub extern "C" fn cosmic_tree_dump(node: c_int) -> *const c_char {
611 }))1066 }))
612 })1067 })
613 }1068 }
1069+
1070+#[cfg(test)]
1071+mod tests {
1072+ use super::*;
1073+
1074+ fn node(t: &mut Tree, parent: i32, tag: &str) -> i32 {
1075+ let id = t.new_node(tag);
1076+ assert!(t.append(parent, id));
1077+ id
1078+ }
1079+
1080+ #[test]
1081+ fn a_zero_width_request_is_no_request() {
1082+ let mut t = Tree::default();
1083+ let root = t.root();
1084+ let column = node(&mut t, root, "vbox");
1085+ t.set(column, "width-request", Prop::Num(0.0));
1086+ assert_eq!(width_request(t.get(column).unwrap()), None);
1087+ t.set(column, "width-request", Prop::Num(260.0));
1088+ assert_eq!(width_request(t.get(column).unwrap()), Some(260.0));
1089+ }
1090+
1091+ #[test]
1092+ fn a_scroll_is_named_by_its_scroll_key() {
1093+ let mut t = Tree::default();
1094+ let root = t.root();
1095+ let list = node(&mut t, root, "scroll");
1096+ assert_eq!(scroll_name(t.get(list).unwrap(), list), format!("node-{list}"));
1097+ t.set(list, "scroll-key", Prop::Str("messages-#freeq".into()));
1098+ assert_eq!(scroll_name(t.get(list).unwrap(), list), "messages-#freeq");
1099+ }
1100+
1101+ #[test]
1102+ fn a_new_scroll_here_asks_for_its_row_and_a_standing_one_does_not() {
1103+ let mut t = Tree::default();
1104+ let root = t.root();
1105+ let list = node(&mut t, root, "scroll");
1106+ t.set(list, "scroll-key", Prop::Str("backlog".into()));
1107+ let rows: Vec<i32> = (0..5).map(|_| node(&mut t, list, "vbox")).collect();
1108+ let before = t.clone();
1109+ t.set(rows[3], "scroll-here", Prop::Bool(true));
1110+
1111+ let asks = scroll_asks(&before, &t);
1112+ assert_eq!(asks.len(), 1);
1113+ assert!(!asks[0].fresh);
1114+ assert_eq!(asks[0].reveal, Some(0.75));
1115+
1116+ let again = scroll_asks(&t, &t);
1117+ assert_eq!(again[0].reveal, None);
1118+ }
1119+
1120+ #[test]
1121+ fn a_jump_is_the_counter_moving() {
1122+ let mut t = Tree::default();
1123+ let root = t.root();
1124+ let list = node(&mut t, root, "scroll");
1125+ t.set(list, "scroll-to-bottom", Prop::Num(1.0));
1126+ let before = t.clone();
1127+ t.set(list, "scroll-to-bottom", Prop::Num(2.0));
1128+ let asks = scroll_asks(&before, &t);
1129+ assert_eq!((asks[0].tick_before, asks[0].tick), (Some(1.0), Some(2.0)));
1130+ }
1131+}
modified glimmer-backends/glimmer-cosmic/README.md +16 -3
@@ -43,9 +43,22 @@ worker (future) mount → commit → wait ─ drain, dispatch, timers → commi
4343 ## Spike limits
4444
4545 * Tags: `:window` `:box`/`:hbox`/`:vbox` `:page` `:card` `:frame` `:scroll`
46- `:label` `:title` `:title-2` `:dim-label` `:button` `:checkbutton` `:entry`
47- `:separator` `:spacer` `:progress`. Anything else paints as a column.
48-* Events: `click`, `toggled`, `change`, `activate`.
46+ `:label` `:title` `:title-2` `:dim-label` `:button` `:link` `:checkbutton`
47+ `:entry` `:separator` `:spacer` `:progress` `:status` `:spinner` `:image`
48+ `:avatar` `:emoji` `:reaction`. Anything else paints as a column.
49+* Layout follows glimmer-jvui: a container fills its parent's cross axis,
50+ `:fill-height` takes what is left, `:width-request 0` is no request, and
51+ `:margin`/`:margin-top``:spacing` `:align` are read.
52+* `:scroll` fills both ways, follows new rows while `:stick-to-bottom` and the
53+ reader is at the end, jumps when `:scroll-to-bottom` changes, keeps its place
54+ by `:scroll-key` across a remount, reports `"end"`/`"away"` on `:on-change`,
55+ and moves to a row that sets `:scroll-here` — by the row's index, since there
56+ is no layout to ask, so it is close rather than exact in a list of rows of
57+ different heights.
58+* Events: `click`, `toggled`, `change`, `activate`, `hover`, `unhover`.
59+* Not yet: `:image {:feed …}` (live frames; the slot is drawn empty), a
60+ wrapping `:hbox {:wrap true}`, `:on-rows` `:on-paste-empty` `:on-key` on an
61+ entry, `:max-rows` (the entry is one line).
4962 * One window per process — winit cannot make a second event loop — so there is
5063 no REPL `start!`/`stop!` yet, and no headless test beyond the arena's own
5164 Rust tests.
@@ -43,9 +43,22 @@ worker (future) mount → commit → wait ─ drain, dispatch, timers → commi
43 ## Spike limits43 ## Spike limits
44 44
45 * Tags: `:window` `:box`/`:hbox`/`:vbox` `:page` `:card` `:frame` `:scroll`45 * Tags: `:window` `:box`/`:hbox`/`:vbox` `:page` `:card` `:frame` `:scroll`
46- `:label` `:title` `:title-2` `:dim-label` `:button` `:checkbutton` `:entry`46+ `:label` `:title` `:title-2` `:dim-label` `:button` `:link` `:checkbutton`
47- `:separator` `:spacer` `:progress`. Anything else paints as a column.47+ `:entry` `:separator` `:spacer` `:progress` `:status` `:spinner` `:image`
48-* Events: `click`, `toggled`, `change`, `activate`.48+ `:avatar` `:emoji` `:reaction`. Anything else paints as a column.
49+* Layout follows glimmer-jvui: a container fills its parent's cross axis,
50+ `:fill-height` takes what is left, `:width-request 0` is no request, and
51+ `:margin`/`:margin-top``:spacing` `:align` are read.
52+* `:scroll` fills both ways, follows new rows while `:stick-to-bottom` and the
53+ reader is at the end, jumps when `:scroll-to-bottom` changes, keeps its place
54+ by `:scroll-key` across a remount, reports `"end"`/`"away"` on `:on-change`,
55+ and moves to a row that sets `:scroll-here` — by the row's index, since there
56+ is no layout to ask, so it is close rather than exact in a list of rows of
57+ different heights.
58+* Events: `click`, `toggled`, `change`, `activate`, `hover`, `unhover`.
59+* Not yet: `:image {:feed …}` (live frames; the slot is drawn empty), a
60+ wrapping `:hbox {:wrap true}`, `:on-rows` `:on-paste-empty` `:on-key` on an
61+ entry, `:max-rows` (the entry is one line).
49 * One window per process — winit cannot make a second event loop — so there is62 * One window per process — winit cannot make a second event loop — so there is
50 no REPL `start!`/`stop!` yet, and no headless test beyond the arena's own63 no REPL `start!`/`stop!` yet, and no headless test beyond the arena's own
51 Rust tests.64 Rust tests.
modified glimmer-backends/glimmer-cosmic/src/glimmer_cosmic/core.clj +4 -0
@@ -177,6 +177,10 @@
177177 "toggled" (when-let [f (:on-toggled hs)] (f))
178178 "change" (when-let [f (:on-change hs)] (f (ffi/event-text)))
179179 "activate" (when-let [f (:on-activate hs)] (f))
180+ ;; The two edges of a hover, as glimmer-vidya sends them: once when
181+ ;; the pointer comes onto the widget and once when it leaves.
182+ "hover" (when-let [f (:on-hover hs)] (f))
183+ "unhover" (when-let [f (:on-unhover hs)] (f))
180184 nil))
181185 (recur))))
182186
@@ -177,6 +177,10 @@
177 "toggled" (when-let [f (:on-toggled hs)] (f))177 "toggled" (when-let [f (:on-toggled hs)] (f))
178 "change" (when-let [f (:on-change hs)] (f (ffi/event-text)))178 "change" (when-let [f (:on-change hs)] (f (ffi/event-text)))
179 "activate" (when-let [f (:on-activate hs)] (f))179 "activate" (when-let [f (:on-activate hs)] (f))
180+ ;; The two edges of a hover, as glimmer-vidya sends them: once when
181+ ;; the pointer comes onto the widget and once when it leaves.
182+ "hover" (when-let [f (:on-hover hs)] (f))
183+ "unhover" (when-let [f (:on-unhover hs)] (f))
180 nil))184 nil))
181 (recur))))185 (recur))))
182 186