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>
7703c75 parent: 6a3304d modified
Cargo.lock +15 -1 | @@ -3218,6 +3218,16 @@ dependencies = [ | ||
| 3218 | 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 | 3231 | [[package]] |
| 3222 | 3232 | name = "gimli" |
| 3223 | 3233 | version = "0.32.3" |
| @@ -4239,6 +4249,9 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" | ||
| 4239 | 4249 | dependencies = [ |
| 4240 | 4250 | "bytemuck", |
| 4241 | 4251 | "byteorder-lite", |
| 4252 | + "color_quant", | |
| 4253 | + "gif 0.14.2", | |
| 4254 | + "image-webp", | |
| 4242 | 4255 | "moxcms", |
| 4243 | 4256 | "num-traits", |
| 4244 | 4257 | "png 0.18.1", |
| @@ -4832,6 +4845,7 @@ dependencies = [ | ||
| 4832 | 4845 | name = "jolt-cosmic" |
| 4833 | 4846 | version = "0.1.0" |
| 4834 | 4847 | dependencies = [ |
| 4848 | + "image", | |
| 4835 | 4849 | "jolt-abi", |
| 4836 | 4850 | "libcosmic", |
| 4837 | 4851 | "log", |
| @@ -7887,7 +7901,7 @@ version = "0.45.1" | ||
| 7887 | 7901 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 7888 | 7902 | checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" |
| 7889 | 7903 | dependencies = [ |
| 7890 | - "gif", | |
| 7904 | + "gif 0.13.3", | |
| 7891 | 7905 | "image-webp", |
| 7892 | 7906 | "log", |
| 7893 | 7907 | "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"] | ||
| 22 | 22 | jolt-abi.workspace = true |
| 23 | 23 | 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 | 31 | # Pinned to the rev the COSMIC applet in wayland-bar already builds against. |
| 26 | 32 | [dependencies.libcosmic] |
| 27 | 33 | git = "https://github.com/pop-os/libcosmic" |
| @@ -22,6 +22,12 @@ crate-type = ["cdylib", "rlib"] | |||
| 22 | jolt-abi.workspace = true | 22 | jolt-abi.workspace = true |
| 23 | log.workspace = true | 23 | 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; | ||
| 23 | 23 | |
| 24 | 24 | pub use tree::{Node, Prop, Tree}; |
| 25 | 25 | |
| 26 | -use std::collections::VecDeque; | |
| 26 | +use std::collections::{HashMap, HashSet, VecDeque}; | |
| 27 | 27 | use std::ffi::{c_char, c_int}; |
| 28 | 28 | use std::sync::atomic::{AtomicBool, Ordering::SeqCst}; |
| 29 | 29 | use std::sync::{Arc, Condvar, LazyLock, Mutex, MutexGuard}; |
| @@ -32,7 +32,9 @@ use std::time::Duration; | ||
| 32 | 32 | use cosmic::app::{Core, Task}; |
| 33 | 33 | use cosmic::iced::futures::channel::mpsc; |
| 34 | 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 | 38 | use cosmic::widget::{self, Column, Row}; |
| 37 | 39 | use cosmic::{ApplicationExt, Element}; |
| 38 | 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 | 245 | // --- the app ----------------------------------------------------------------- |
| 139 | 246 | |
| 140 | 247 | struct App { |
| 141 | 248 | core: Core, |
| 142 | 249 | tree: Arc<Tree>, |
| 250 | + scrolls: HashMap<String, ScrollMemo>, | |
| 143 | 251 | } |
| 144 | 252 | |
| 145 | 253 | #[derive(Clone, Debug)] |
| @@ -150,6 +258,9 @@ enum Message { | ||
| 150 | 258 | Toggled(i32, bool), |
| 151 | 259 | Change(i32, String), |
| 152 | 260 | Activate(i32), |
| 261 | + Hover(i32), | |
| 262 | + Unhover(i32), | |
| 263 | + Scrolled(i32, String, Viewport), | |
| 153 | 264 | } |
| 154 | 265 | |
| 155 | 266 | impl App { |
| @@ -160,6 +271,67 @@ impl App { | ||
| 160 | 271 | edit(|t| t.set(node, key, value.clone())); |
| 161 | 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 | 337 | impl cosmic::Application for App { |
| @@ -180,6 +352,7 @@ impl cosmic::Application for App { | ||
| 180 | 352 | let mut app = App { |
| 181 | 353 | core, |
| 182 | 354 | tree: lock(&COMMITTED).clone(), |
| 355 | + scrolls: HashMap::new(), | |
| 183 | 356 | }; |
| 184 | 357 | // libcosmic's `wayland` feature brings `multi-window` with it, which |
| 185 | 358 | // makes a window title a per-window thing. |
| @@ -197,7 +370,7 @@ impl cosmic::Application for App { | ||
| 197 | 370 | |
| 198 | 371 | fn update(&mut self, message: Message) -> Task<Message> { |
| 199 | 372 | match message { |
| 200 | - Message::Tree => self.tree = lock(&COMMITTED).clone(), | |
| 373 | + Message::Tree => return self.take_tree(), | |
| 201 | 374 | Message::Quit => return cosmic::iced::exit(), |
| 202 | 375 | Message::Click(node) => post(node, "click", String::new(), 0.0), |
| 203 | 376 | Message::Toggled(node, on) => { |
| @@ -209,49 +382,161 @@ impl cosmic::Application for App { | ||
| 209 | 382 | post(node, "change", text, 0.0); |
| 210 | 383 | } |
| 211 | 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 | 389 | Task::none() |
| 214 | 390 | } |
| 215 | 391 | |
| 216 | 392 | fn view(&self) -> Element<'_, Message> { |
| 217 | 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: 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> { | |
| 225 | 485 | let Some(n) = t.get(id) else { |
| 226 | 486 | return Column::new().into(); |
| 227 | 487 | }; |
| 228 | 488 | 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)); | |
| 231 | 492 | |
| 232 | 493 | let el: Element<'_, Message> = match n.tag.as_str() { |
| 233 | - "window" => Column::with_children(children()) | |
| 494 | + "window" => Column::with_children(children(false)) | |
| 234 | 495 | .width(Length::Fill) |
| 235 | 496 | .height(Length::Fill) |
| 236 | 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 | 503 | "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)) | |
| 241 | 507 | .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() | |
| 245 | 521 | } else { |
| 246 | - Column::with_children(children()) | |
| 522 | + let mut column = Column::with_children(children(false)) | |
| 247 | 523 | .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() | |
| 250 | 535 | } |
| 251 | 536 | } |
| 252 | 537 | "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) | |
| 255 | 540 | .padding(24) |
| 256 | 541 | .width(Length::Fill); |
| 257 | 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 | 544 | inner = inner.max_width(max as f32); |
| 260 | 545 | } |
| 261 | 546 | widget::scrollable(widget::container(inner).center_x(Length::Fill)) |
| 547 | + .width(Length::Fill) | |
| 262 | 548 | .height(Length::Fill) |
| 263 | 549 | .into() |
| 264 | 550 | } |
| 265 | 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 | 553 | if n.tag == "frame" && !n.label().is_empty() { |
| 268 | 554 | column = column.push(widget::text::heading(n.label())); |
| 269 | 555 | } |
| 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)) | |
| 272 | 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 | 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 | 583 | "label" => widget::text::body(n.label()).into(), |
| 278 | 584 | "title" => widget::text::title3(n.label()).into(), |
| 279 | 585 | "title-2" => widget::text::title4(n.label()).into(), |
| @@ -288,6 +594,142 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> { | ||
| 288 | 594 | .on_press_maybe(enabled.then_some(Message::Click(id))) |
| 289 | 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 | 733 | "checkbutton" => { |
| 292 | 734 | let mut check = widget::checkbox(n.bool("active").unwrap_or(false)).label(n.label()); |
| 293 | 735 | if enabled { |
| @@ -302,12 +744,20 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> { | ||
| 302 | 744 | .on_input(move |text| Message::Change(id, text)) |
| 303 | 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 | 753 | "separator" => widget::divider::horizontal::default().into(), |
| 308 | 754 | "spacer" => { |
| 309 | 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 | 762 | "progress" => { |
| 313 | 763 | let bar = |
| @@ -324,12 +774,17 @@ fn element(t: &Tree, id: i32, enabled: bool) -> Element<'_, Message> { | ||
| 324 | 774 | } |
| 325 | 775 | // Kept rather than refused, as in libvidya: a tag this backend has not |
| 326 | 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") { | |
| 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(), | |
| 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 | +} | |
| @@ -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, which | 357 | // 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: an | 427 | +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 not | 775 | // 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 | ||
| 43 | 43 | ## Spike limits |
| 44 | 44 | |
| 45 | 45 | * 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). | |
| 49 | 62 | * One window per process — winit cannot make a second event loop — so there is |
| 50 | 63 | no REPL `start!`/`stop!` yet, and no headless test beyond the arena's own |
| 51 | 64 | Rust tests. |
| @@ -43,9 +43,22 @@ worker (future) mount → commit → wait ─ drain, dispatch, timers → commi | |||
| 43 | ## Spike limits | 43 | ## 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 is | 62 | * 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 own | 63 | 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 @@ | ||
| 177 | 177 | "toggled" (when-let [f (:on-toggled hs)] (f)) |
| 178 | 178 | "change" (when-let [f (:on-change hs)] (f (ffi/event-text))) |
| 179 | 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 | 184 | nil)) |
| 181 | 185 | (recur)))) |
| 182 | 186 | |
| @@ -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 | ||