| Bring vidya in cfd3e36 nandi 19d ago | 1 | //! A retained node tree, painted immediately. |
| 2 | //! |
| 3 | //! The push/pop half of this ABI (`vidya_card_begin` … `vidya_card_end`) suits |
| 4 | //! a caller that writes its UI out top to bottom every frame. A *reactive* |
| 5 | //! caller does not: glimmer keeps a component tree, reconciles it against new |
| 6 | //! hiccup, and emits create/patch/append/remove against whatever the toolkit |
| 7 | //! calls a widget. GTK has widgets to hand it; egui has none. |
| 8 | //! |
| 9 | //! So this module is the widget layer glimmer expects, on the Rust side of the |
| 10 | //! FFI. The caller gets integer node handles and mutates them — set a prop, |
| 11 | //! append a child, drop a subtree. Nothing is drawn by those calls. Once a |
| 12 | //! frame, [`Tree::paint`] walks the whole tree and emits the egui calls it |
| 13 | //! describes, and interactions come back out as a queue of events the caller |
| 14 | //! drains and routes to its own handlers. |
| 15 | //! |
| 16 | //! Two things fall out of that split that the push/pop ABI could not have: |
| 17 | //! |
| 18 | //! * **Closure-shaped egui APIs work.** `ScrollArea`, `Frame` and friends take |
| 19 | //! an `FnOnce(&mut Ui)` and keep their `begin`/`end` private, which is why |
| 20 | //! `vidya_page_begin` had to reimplement scrolling by hand and why the page |
| 21 | //! was documented as non-scrolling. Painting from a tree we already hold |
| 22 | //! means the recursion *is* the closure; nothing has to stay open across a |
| 23 | //! call boundary. |
| 24 | //! * **FFI traffic tracks edits, not frames.** A static UI at 60fps costs zero |
| 25 | //! crossings per frame; only what the reconciler actually changed is sent. |
| 26 | //! |
| 27 | //! The tree deliberately knows nothing about egui until [`Tree::paint`], so the |
| 28 | //! arena and its edit operations are unit-testable with no window. |
| 29 | |
| 30 | use std::collections::HashMap; |
| 31 | use std::collections::VecDeque; |
| 32 | |
| 33 | use egui::{Align, Align2, Color32, FontId, Id, Layout, Margin, TextureOptions, Ui, Vec2}; |
| 34 | use vidya_core::Theme; |
| 35 | |
| 36 | /// A prop value. The three types the ABI can carry, and all glimmer needs: |
| 37 | /// keywords and colours arrive as strings, numbers as doubles, flags as ints. |
| 38 | #[derive(Clone, Debug, PartialEq)] |
| 39 | pub enum Value { |
| 40 | Str(String), |
| 41 | Num(f64), |
| 42 | Bool(bool), |
| 43 | } |
| 44 | |
| 45 | /// What a node renders as. Unknown tags are kept rather than rejected: they |
| 46 | /// paint as a plain vertical box, so a caller using a tag this backend has not |
| 47 | /// grown yet still sees its children instead of nothing. |
| 48 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 49 | pub enum Tag { |
| 50 | Window, |
| 51 | Box, |
| 52 | Page, |
| 53 | Card, |
| 54 | Frame, |
| 55 | Scroll, |
| 56 | Label, |
| 57 | Link, |
| 58 | Title, |
| 59 | Title2, |
| 60 | DimLabel, |
| 61 | Button, |
| 62 | CheckButton, |
| 63 | Entry, |
| 64 | Separator, |
| 65 | Spacer, |
| 66 | Progress, |
| 67 | Spinner, |
| 68 | Image, |
| 69 | Avatar, |
| 70 | Reaction, |
| 71 | Status, |
| 72 | /// A tag this backend has not grown yet, keeping the name it was created |
| 73 | /// with so a dump answers what the caller actually asked for. |
| 74 | Unknown(String), |
| 75 | } |
| 76 | |
| 77 | impl Tag { |
| 78 | /// Parse a hiccup tag name. `:hbox`/`:vbox` are the same box — the tag only |
| 79 | /// implies an orientation, which the caller sets as a prop. |
| 80 | fn parse(name: &str) -> Self { |
| 81 | match name { |
| 82 | "window" => Self::Window, |
| 83 | "box" | "hbox" | "vbox" => Self::Box, |
| 84 | "page" => Self::Page, |
| 85 | "card" => Self::Card, |
| 86 | "frame" => Self::Frame, |
| 87 | "scroll" => Self::Scroll, |
| 88 | "label" => Self::Label, |
| 89 | "link" => Self::Link, |
| 90 | "title" => Self::Title, |
| 91 | "title-2" => Self::Title2, |
| 92 | "dim-label" => Self::DimLabel, |
| 93 | "button" => Self::Button, |
| 94 | "checkbutton" | "checkbox" => Self::CheckButton, |
| 95 | "entry" => Self::Entry, |
| 96 | "separator" => Self::Separator, |
| 97 | "spacer" | "gap" => Self::Spacer, |
| 98 | "progress" => Self::Progress, |
| 99 | "spinner" => Self::Spinner, |
| 100 | "image" => Self::Image, |
| 101 | "avatar" => Self::Avatar, |
| 102 | "reaction" => Self::Reaction, |
| 103 | "status" => Self::Status, |
| 104 | other => Self::Unknown(other.to_owned()), |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /// The canonical name of a parsed tag: `:hbox` and `:vbox` both answer |
| 109 | /// `box`, since the orientation lives in a prop rather than in the tag. |
| 110 | fn name(&self) -> &str { |
| 111 | match self { |
| 112 | Self::Window => "window", |
| 113 | Self::Box => "box", |
| 114 | Self::Page => "page", |
| 115 | Self::Card => "card", |
| 116 | Self::Frame => "frame", |
| 117 | Self::Scroll => "scroll", |
| 118 | Self::Label => "label", |
| 119 | Self::Link => "link", |
| 120 | Self::Title => "title", |
| 121 | Self::Title2 => "title-2", |
| 122 | Self::DimLabel => "dim-label", |
| 123 | Self::Button => "button", |
| 124 | Self::CheckButton => "checkbutton", |
| 125 | Self::Entry => "entry", |
| 126 | Self::Separator => "separator", |
| 127 | Self::Spacer => "spacer", |
| 128 | Self::Progress => "progress", |
| 129 | Self::Spinner => "spinner", |
| 130 | Self::Image => "image", |
| 131 | Self::Avatar => "avatar", |
| 132 | Self::Reaction => "reaction", |
| 133 | Self::Status => "status", |
| 134 | Self::Unknown(name) => name, |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | /// One interaction, waiting to be drained by the caller. |
| 140 | /// |
| 141 | /// Names match glimmer's handler props with the `on-` dropped: `click` pairs |
| 142 | /// with `:on-click`, `change` with `:on-change`, and so on. `text` and `num` |
| 143 | /// carry the payload the handler is called with, empty when it takes none. |
| 144 | #[derive(Clone, Debug, PartialEq)] |
| 145 | pub struct Event { |
| 146 | pub node: u32, |
| 147 | pub name: &'static str, |
| 148 | pub text: String, |
| 149 | pub num: f64, |
| 150 | } |
| 151 | |
| 152 | /// One prop value as EDN. Numbers that happen to be whole print without a |
| 153 | /// trailing `.0`, since every number crossed the boundary as a double and |
| 154 | /// `{:spacing 8.0}` reads worse than `{:spacing 8}`. |
| 155 | fn write_value(value: &Value, out: &mut String) { |
| 156 | match value { |
| 157 | Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }), |
| 158 | Value::Num(n) => { |
| 159 | if n.is_finite() && n.fract() == 0.0 && n.abs() < 1e15 { |
| 160 | out.push_str(&format!("{}", *n as i64)); |
| 161 | } else if n.is_finite() { |
| 162 | out.push_str(&format!("{n}")); |
| 163 | } else { |
| 164 | // EDN has no infinity or NaN literal; say so rather than emit |
| 165 | // something no reader will take. |
| 166 | out.push_str("nil"); |
| 167 | } |
| 168 | } |
| 169 | Value::Str(text) => { |
| 170 | out.push('"'); |
| 171 | for c in text.chars() { |
| 172 | match c { |
| 173 | '"' => out.push_str("\\\""), |
| 174 | '\\' => out.push_str("\\\\"), |
| 175 | '\n' => out.push_str("\\n"), |
| 176 | '\r' => out.push_str("\\r"), |
| 177 | '\t' => out.push_str("\\t"), |
| 178 | _ => out.push(c), |
| 179 | } |
| 180 | } |
| 181 | out.push('"'); |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | #[derive(Clone, Debug, Default)] |
| 187 | struct Node { |
| 188 | tag: Tag, |
| 189 | props: HashMap<String, Value>, |
| 190 | children: Vec<u32>, |
| 191 | /// 0 when unparented. The root's parent is 0 too, which is what stops the |
| 192 | /// ancestor walk in [`Tree::would_cycle`]. |
| 193 | parent: u32, |
| 194 | } |
| 195 | |
| 196 | impl Default for Tag { |
| 197 | fn default() -> Self { |
| 198 | Self::Unknown(String::new()) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | /// One named source of live pixels: what has arrived, and what is on the GPU. |
| 203 | #[derive(Default)] |
| 204 | struct Feed { |
| 205 | /// Pixels written since the last paint, waiting to be uploaded. Taken (not |
| 206 | /// copied) by the paint that consumes them. |
| 207 | pending: Option<egui::ColorImage>, |
| 208 | /// The texture the last upload produced. Kept when nothing new arrives, so |
| 209 | /// a still source keeps painting instead of blinking out between frames. |
| 210 | texture: Option<egui::TextureHandle>, |
| 211 | } |
| 212 | |
| 213 | /// The node arena. |
| 214 | /// |
| 215 | /// Handles are `index + 1`, so 0 is always "no node" — the value C gets back |
| 216 | /// from a failed allocation and the sibling argument that means "first". |
| 217 | /// Freed slots are reused, so a list that churns rows does not grow the arena. |
| 218 | pub struct Tree { |
| 219 | nodes: Vec<Option<Node>>, |
| 220 | free: Vec<u32>, |
| 221 | root: u32, |
| 222 | /// Decoded images, by the path they came from. An `:image` node is walked |
| 223 | /// every frame and must not decode a file every time. |
| 224 | textures: HashMap<String, Option<egui::TextureHandle>>, |
| 225 | /// Live pixels pushed in by name, for an `:image` with a `feed` rather |
| 226 | /// than a `src`. A caller that has frames of its own — a camera, a video |
| 227 | /// decoder, a renderer — writes them here and the tag paints the latest. |
| 228 | /// |
| 229 | /// Two halves, because the writer is not in a frame and the uploader is: |
| 230 | /// `pending` is what arrived since the last paint, `texture` is what was |
| 231 | /// uploaded from it. A frame that arrives twice between paints overwrites |
| 232 | /// the first, so a 30fps source cannot outrun a 60fps window into a queue. |
| 233 | feeds: HashMap<String, Feed>, |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 234 | /// The width a centred row measured last frame, by node id. A row is |
| 235 | /// indented to the middle of the space it is given, and nothing here |
| 236 | /// knows how wide it is until it has been painted once — so the previous |
| 237 | /// frame's width is what the indent is computed from. Kept here rather |
| 238 | /// than written back onto the node: props are cleared and rewritten on |
| 239 | /// every re-render, and a row would jump to the left edge for a frame on |
| 240 | /// every keystroke typed into it. |
| 241 | row_widths: HashMap<u32, f32>, |
| Bring vidya in cfd3e36 nandi 19d ago | 242 | pending: VecDeque<Event>, |
| 243 | /// The event most recently dequeued by `poll`, whose fields the accessors |
| 244 | /// read. Held here so the ABI can return a payload without out-parameters. |
| 245 | current: Option<Event>, |
| 246 | } |
| 247 | |
| 248 | /// A stable colour for a name: the same person is the same colour every time, |
| 249 | /// and two people are unlikely to share one. Kept dark enough for the light |
| 250 | /// text drawn on top and dull enough not to compete with the accent. |
| 251 | fn name_colour(name: &str, theme: &Theme) -> Color32 { |
| 252 | let mut hash: u32 = 2166136261; |
| 253 | for b in name.as_bytes() { |
| 254 | hash ^= *b as u32; |
| 255 | hash = hash.wrapping_mul(16777619); |
| 256 | } |
| 257 | // Six hues around the wheel, at a fixed saturation and value, rather than |
| 258 | // free RGB: random channels give muddy colours as often as good ones. |
| 259 | let sector = (hash % 6) as f32; |
| 260 | let (r, g, b) = match sector as u32 { |
| 261 | 0 => (0.80, 0.35, 0.35), |
| 262 | 1 => (0.80, 0.55, 0.25), |
| 263 | 2 => (0.45, 0.65, 0.35), |
| 264 | 3 => (0.30, 0.60, 0.65), |
| 265 | 4 => (0.40, 0.50, 0.80), |
| 266 | _ => (0.65, 0.40, 0.70), |
| 267 | }; |
| 268 | let _ = theme; |
| 269 | Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8) |
| 270 | } |
| 271 | |
| 272 | impl Tree { |
| 273 | /// Whether anything under `id` has `:scroll-here` set this frame. |
| 274 | /// |
| 275 | /// Walked rather than remembered: the prop is set for the moment of a jump |
| 276 | /// and taken off again, so there is nothing to keep, and this runs once |
| 277 | /// per scroll area rather than once per node. |
| 278 | fn wants_scroll_to(&self, id: u32) -> bool { |
| 279 | let Some(node) = self.slot(id) else { |
| 280 | return false; |
| 281 | }; |
| 282 | matches!(node.props.get("scroll-here"), Some(Value::Bool(true))) |
| 283 | || node |
| 284 | .children |
| 285 | .iter() |
| 286 | .any(|child| self.wants_scroll_to(*child)) |
| 287 | } |
| 288 | |
| 289 | /// The texture for a file, decoding it the first time it is asked for. |
| 290 | /// A file that will not decode is remembered as such, so a bad path costs |
| 291 | /// one failed read rather than one per frame. |
| 292 | /// Hand the tree a frame of live pixels under `key`, to be painted by any |
| 293 | /// `:image` whose `feed` names it. `rgba` is `width * height * 4` bytes, |
| 294 | /// row-major, and is copied — the caller keeps ownership and may reuse the |
| 295 | /// buffer the moment this returns. |
| 296 | /// |
| 297 | /// Rejects a frame whose length disagrees with its dimensions rather than |
| 298 | /// painting torn pixels: a capture path that changes resolution mid-stream |
| 299 | /// otherwise reads the tail of the old buffer as the head of the new one. |
| 300 | pub fn set_frame(&mut self, key: &str, width: u32, height: u32, rgba: &[u8]) -> bool { |
| 301 | if key.is_empty() || width == 0 || height == 0 { |
| 302 | return false; |
| 303 | } |
| 304 | let expected = (width as usize) |
| 305 | .saturating_mul(height as usize) |
| 306 | .saturating_mul(4); |
| 307 | if rgba.len() != expected { |
| 308 | return false; |
| 309 | } |
| 310 | let image = egui::ColorImage::from_rgba_unmultiplied( |
| 311 | [width as usize, height as usize], |
| 312 | rgba, |
| 313 | ); |
| 314 | // Overwrites whatever had not been painted yet: the newest frame is |
| 315 | // the only one worth showing, and a backlog of stale ones is latency. |
| 316 | self.feeds.entry(key.to_owned()).or_default().pending = Some(image); |
| 317 | true |
| 318 | } |
| 319 | |
| 320 | /// Forget a feed and release its texture. A call that ends leaves a tile |
| 321 | /// behind otherwise — the last frame of a participant who has gone. |
| 322 | pub fn drop_frame(&mut self, key: &str) -> bool { |
| 323 | self.feeds.remove(key).is_some() |
| 324 | } |
| 325 | |
| 326 | /// The texture for a feed, uploading this paint's pending frame first. |
| 327 | fn feed_texture(&mut self, ui: &Ui, key: &str) -> Option<egui::TextureHandle> { |
| 328 | let feed = self.feeds.get_mut(key)?; |
| 329 | if let Some(image) = feed.pending.take() { |
| 330 | match feed.texture.as_mut() { |
| 331 | // `set` reuses the allocation when the size is unchanged, |
| 332 | // which is the whole point at video rates. |
| 333 | Some(texture) => texture.set(image, TextureOptions::LINEAR), |
| 334 | None => { |
| 335 | feed.texture = Some(ui.ctx().load_texture( |
| 336 | format!("vidya/tree/feed/{key}"), |
| 337 | image, |
| 338 | TextureOptions::LINEAR, |
| 339 | )) |
| 340 | } |
| 341 | } |
| 342 | } |
| 343 | feed.texture.clone() |
| 344 | } |
| 345 | |
| 346 | fn texture(&mut self, ui: &Ui, path: &str) -> Option<egui::TextureHandle> { |
| 347 | if let Some(cached) = self.textures.get(path) { |
| 348 | return cached.clone(); |
| 349 | } |
| 350 | let handle = std::fs::read(path) |
| 351 | .ok() |
| 352 | .and_then(|bytes| decode_png_rgba(&bytes)) |
| 353 | .map(|image| { |
| 354 | ui.ctx() |
| 355 | .load_texture(format!("vidya/tree/{path}"), image, TextureOptions::LINEAR) |
| 356 | }); |
| 357 | self.textures.insert(path.to_owned(), handle.clone()); |
| 358 | handle |
| 359 | } |
| 360 | } |
| 361 | |
| 362 | /// PNG bytes as an egui image. PNG alone: it is what the vendored decoder |
| 363 | /// reads, and what the media this paints is served as. |
| 364 | fn decode_png_rgba(bytes: &[u8]) -> Option<egui::ColorImage> { |
| 365 | let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes)); |
| 366 | decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA); |
| 367 | let mut reader = decoder.read_info().ok()?; |
| 368 | let mut buf = vec![0; reader.output_buffer_size()]; |
| 369 | let info = reader.next_frame(&mut buf).ok()?; |
| 370 | let (w, h) = (info.width as usize, info.height as usize); |
| 371 | let raw = &buf[..info.buffer_size()]; |
| 372 | let rgba: Vec<u8> = match info.color_type { |
| 373 | png::ColorType::Rgba => raw.to_vec(), |
| 374 | png::ColorType::Rgb => raw |
| 375 | .chunks_exact(3) |
| 376 | .flat_map(|c| [c[0], c[1], c[2], 255]) |
| 377 | .collect(), |
| 378 | _ => return None, |
| 379 | }; |
| 380 | (rgba.len() == w * h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([w, h], &rgba)) |
| 381 | } |
| 382 | |
| 383 | impl Default for Tree { |
| 384 | fn default() -> Self { |
| 385 | let mut tree = Self { |
| 386 | nodes: Vec::new(), |
| 387 | free: Vec::new(), |
| 388 | root: 0, |
| 389 | textures: HashMap::new(), |
| 390 | feeds: HashMap::new(), |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 391 | row_widths: HashMap::new(), |
| Bring vidya in cfd3e36 nandi 19d ago | 392 | pending: VecDeque::new(), |
| 393 | current: None, |
| 394 | }; |
| 395 | tree.root = tree.new_node("window"); |
| 396 | tree |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | impl Tree { |
| 401 | pub fn root(&self) -> u32 { |
| 402 | self.root |
| 403 | } |
| 404 | |
| 405 | fn slot(&self, id: u32) -> Option<&Node> { |
| 406 | if id == 0 { |
| 407 | return None; |
| 408 | } |
| 409 | self.nodes.get(id as usize - 1).and_then(Option::as_ref) |
| 410 | } |
| 411 | |
| 412 | fn slot_mut(&mut self, id: u32) -> Option<&mut Node> { |
| 413 | if id == 0 { |
| 414 | return None; |
| 415 | } |
| 416 | self.nodes.get_mut(id as usize - 1).and_then(Option::as_mut) |
| 417 | } |
| 418 | |
| 419 | pub fn exists(&self, id: u32) -> bool { |
| 420 | self.slot(id).is_some() |
| 421 | } |
| 422 | |
| 423 | // ── editing ───────────────────────────────────────────────────────────── |
| 424 | |
| 425 | pub fn new_node(&mut self, tag: &str) -> u32 { |
| 426 | let node = Node { |
| 427 | tag: Tag::parse(tag), |
| 428 | ..Node::default() |
| 429 | }; |
| 430 | match self.free.pop() { |
| 431 | Some(id) => { |
| 432 | self.nodes[id as usize - 1] = Some(node); |
| 433 | id |
| 434 | } |
| 435 | None => { |
| 436 | self.nodes.push(Some(node)); |
| 437 | self.nodes.len() as u32 |
| 438 | } |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | /// Drop `id` and everything under it, unparenting it first. |
| 443 | /// |
| 444 | /// glimmer has no separate destroy operation — `remove-child!` is the last |
| 445 | /// the reconciler ever says about a widget — so removal frees, and a node |
| 446 | /// handle the caller still holds after that is simply dead. |
| 447 | pub fn free_node(&mut self, id: u32) { |
| 448 | let parent = match self.slot(id) { |
| 449 | Some(n) => n.parent, |
| 450 | None => return, |
| 451 | }; |
| 452 | self.detach(parent, id); |
| 453 | self.free_subtree(id); |
| 454 | } |
| 455 | |
| 456 | fn free_subtree(&mut self, id: u32) { |
| 457 | let Some(node) = self.slot_mut(id).map(std::mem::take) else { |
| 458 | return; |
| 459 | }; |
| 460 | self.nodes[id as usize - 1] = None; |
| 461 | self.free.push(id); |
| 462 | for child in node.children { |
| 463 | self.free_subtree(child); |
| 464 | } |
| 465 | // An event queued against a node that has since been removed would be |
| 466 | // routed to a handler the caller has already forgotten. |
| 467 | self.pending.retain(|e| e.node != id); |
| 468 | } |
| 469 | |
| 470 | /// Unparent `child` without freeing it. `parent` may be 0 (already loose). |
| 471 | fn detach(&mut self, parent: u32, child: u32) { |
| 472 | if let Some(p) = self.slot_mut(parent) { |
| 473 | p.children.retain(|&c| c != child); |
| 474 | } |
| 475 | if let Some(c) = self.slot_mut(child) { |
| 476 | c.parent = 0; |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | /// True when parenting `child` under `parent` would make a loop — `child` |
| 481 | /// is `parent`, or an ancestor of it. A cycle here is an infinite paint, |
| 482 | /// so it is checked rather than trusted. |
| 483 | fn would_cycle(&self, parent: u32, child: u32) -> bool { |
| 484 | let mut at = parent; |
| 485 | while at != 0 { |
| 486 | if at == child { |
| 487 | return true; |
| 488 | } |
| 489 | at = match self.slot(at) { |
| 490 | Some(n) => n.parent, |
| 491 | None => 0, |
| 492 | }; |
| 493 | } |
| 494 | false |
| 495 | } |
| 496 | |
| 497 | pub fn append(&mut self, parent: u32, child: u32) -> bool { |
| 498 | self.insert_at(parent, child, usize::MAX) |
| 499 | } |
| 500 | |
| 501 | fn insert_at(&mut self, parent: u32, child: u32, index: usize) -> bool { |
| 502 | if parent == 0 || child == 0 || !self.exists(parent) || !self.exists(child) { |
| 503 | return false; |
| 504 | } |
| 505 | if self.would_cycle(parent, child) { |
| 506 | return false; |
| 507 | } |
| 508 | // Moving a child that already has a parent (including this one) is a |
| 509 | // reparent, not a duplicate: take it out first so it appears once. |
| 510 | let old_parent = self.slot(child).map_or(0, |n| n.parent); |
| 511 | self.detach(old_parent, child); |
| 512 | |
| 513 | let p = self.slot_mut(parent).expect("checked above"); |
| 514 | let at = index.min(p.children.len()); |
| 515 | p.children.insert(at, child); |
| 516 | self.slot_mut(child).expect("checked above").parent = parent; |
| 517 | true |
| 518 | } |
| 519 | |
| 520 | pub fn remove(&mut self, parent: u32, child: u32) { |
| 521 | if self.slot(child).map_or(true, |n| n.parent != parent) { |
| 522 | return; |
| 523 | } |
| 524 | self.free_node(child); |
| 525 | } |
| 526 | |
| 527 | /// Move `child` to sit immediately after `sibling`; `sibling` 0 means first. |
| 528 | /// glimmer's keyed reconciliation calls this to reorder a list without |
| 529 | /// rebuilding the widgets in it. |
| 530 | pub fn insert_after(&mut self, parent: u32, child: u32, sibling: u32) -> bool { |
| 531 | if !self.exists(parent) || !self.exists(child) { |
| 532 | return false; |
| 533 | } |
| 534 | let index = if sibling == 0 { |
| 535 | 0 |
| 536 | } else { |
| 537 | match self |
| 538 | .slot(parent) |
| 539 | .and_then(|p| p.children.iter().position(|&c| c == sibling)) |
| 540 | { |
| 541 | // The sibling's own slot, once `child` is out of the way, is |
| 542 | // the position after it. |
| 543 | Some(i) => i + 1, |
| 544 | None => return false, |
| 545 | } |
| 546 | }; |
| 547 | // Re-derive the index after detaching: removing `child` from earlier in |
| 548 | // the list shifts everything after it down one. |
| 549 | let before = self |
| 550 | .slot(parent) |
| 551 | .and_then(|p| p.children.iter().position(|&c| c == child)) |
| 552 | .map_or(false, |i| i < index); |
| 553 | self.insert_at(parent, child, if before { index - 1 } else { index }) |
| 554 | } |
| 555 | |
| 556 | pub fn replace(&mut self, parent: u32, old: u32, new: u32) -> bool { |
| 557 | let Some(index) = self |
| 558 | .slot(parent) |
| 559 | .and_then(|p| p.children.iter().position(|&c| c == old)) |
| 560 | else { |
| 561 | return false; |
| 562 | }; |
| 563 | if !self.insert_at(parent, new, index) { |
| 564 | return false; |
| 565 | } |
| 566 | self.remove(parent, old); |
| 567 | true |
| 568 | } |
| 569 | |
| 570 | /// The canonical tag name, or the empty string for a node that is not |
| 571 | /// there. With [`Tree::child_count`] and [`Tree::child_at`] this is enough |
| 572 | /// for a caller to read back the tree it built — which is how the jolt |
| 573 | /// backend's tests assert against a real reconcile with no window open. |
| 574 | pub fn tag_name(&self, id: u32) -> &str { |
| 575 | self.slot(id).map_or("", |n| n.tag.name()) |
| 576 | } |
| 577 | |
| 578 | pub fn child_count(&self, id: u32) -> usize { |
| 579 | self.slot(id).map_or(0, |n| n.children.len()) |
| 580 | } |
| 581 | |
| 582 | pub fn child_at(&self, id: u32, index: usize) -> u32 { |
| 583 | self.slot(id) |
| 584 | .and_then(|n| n.children.get(index)) |
| 585 | .copied() |
| 586 | .unwrap_or(0) |
| 587 | } |
| 588 | |
| 589 | // ── props ─────────────────────────────────────────────────────────────── |
| 590 | |
| 591 | pub fn set(&mut self, id: u32, key: &str, value: Value) { |
| 592 | if let Some(node) = self.slot_mut(id) { |
| 593 | node.props.insert(key.to_owned(), value); |
| 594 | } |
| 595 | } |
| 596 | |
| 597 | pub fn clear_props(&mut self, id: u32) { |
| 598 | if let Some(node) = self.slot_mut(id) { |
| 599 | node.props.clear(); |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | pub fn get(&self, id: u32, key: &str) -> Option<&Value> { |
| 604 | self.slot(id).and_then(|n| n.props.get(key)) |
| 605 | } |
| 606 | |
| 607 | // ── reading it back as hiccup ─────────────────────────────────────────── |
| 608 | |
| 609 | /// The subtree at `id` as pretty-printed hiccup, in the same shape the |
| 610 | /// caller wrote: `[:tag {props} children…]`, one node to a line. |
| 611 | /// |
| 612 | /// This is what the tree *is*, not what a component said — it is read from |
| 613 | /// the arena after the reconciler has had its way with it, so a patch that |
| 614 | /// went to the wrong node shows up here as a difference from the source. |
| 615 | /// |
| 616 | /// A node that does not exist dumps as `nil`. `:hbox` and `:vbox` both |
| 617 | /// dump as `:box`, as they are both stored as one; their orientation is in |
| 618 | /// the props. Handlers are not here — they never crossed the boundary. |
| 619 | pub fn dump(&self, id: u32) -> String { |
| 620 | let mut out = String::new(); |
| 621 | self.dump_into(id, 0, &mut out); |
| 622 | out |
| 623 | } |
| 624 | |
| 625 | fn dump_into(&self, id: u32, depth: usize, out: &mut String) { |
| 626 | let Some(node) = self.slot(id) else { |
| 627 | out.push_str("nil"); |
| 628 | return; |
| 629 | }; |
| 630 | let indent = " ".repeat(depth); |
| 631 | out.push_str("[:"); |
| 632 | out.push_str(node.tag.name()); |
| 633 | |
| 634 | // Sorted, so two dumps of the same tree compare as text. |
| 635 | let mut keys: Vec<&String> = node.props.keys().collect(); |
| 636 | keys.sort(); |
| 637 | out.push_str(" {"); |
| 638 | for (i, key) in keys.iter().enumerate() { |
| 639 | if i > 0 { |
| 640 | out.push(' '); |
| 641 | } |
| 642 | out.push(':'); |
| 643 | out.push_str(key); |
| 644 | out.push(' '); |
| 645 | write_value(&node.props[*key], out); |
| 646 | } |
| 647 | out.push('}'); |
| 648 | |
| 649 | for child in &node.children { |
| 650 | out.push('\n'); |
| 651 | out.push_str(&indent); |
| 652 | out.push_str(" "); |
| 653 | self.dump_into(*child, depth + 1, out); |
| 654 | } |
| 655 | out.push(']'); |
| 656 | } |
| 657 | |
| 658 | // ── events ────────────────────────────────────────────────────────────── |
| 659 | |
| 660 | fn emit(&mut self, node: u32, name: &'static str, text: String, num: f64) { |
| 661 | self.pending.push_back(Event { |
| 662 | node, |
| 663 | name, |
| 664 | text, |
| 665 | num, |
| 666 | }); |
| 667 | } |
| 668 | |
| 669 | /// Dequeue one event into the accessor slot. False when the queue is empty. |
| 670 | pub fn poll(&mut self) -> bool { |
| 671 | self.current = self.pending.pop_front(); |
| 672 | self.current.is_some() |
| 673 | } |
| 674 | |
| 675 | pub fn current(&self) -> Option<&Event> { |
| 676 | self.current.as_ref() |
| 677 | } |
| 678 | |
| 679 | // ── painting ──────────────────────────────────────────────────────────── |
| 680 | |
| 681 | /// Emit the whole tree into `ui`. Called once per frame. |
| 682 | pub fn paint(&mut self, ui: &mut Ui, theme: &Theme) { |
| 683 | let root = self.root; |
| 684 | self.paint_node(root, ui, theme); |
| 685 | } |
| 686 | |
| 687 | fn paint_children(&mut self, id: u32, ui: &mut Ui, theme: &Theme) { |
| 688 | // The child list is copied rather than borrowed: painting a child can |
| 689 | // write a prop back (an entry's text) or queue an event, both of which |
| 690 | // need `&mut self` while the walk is in flight. A UI's worth of `u32`s |
| 691 | // is a cheap price for not threading a cell through every widget. |
| 692 | let children = self |
| 693 | .slot(id) |
| 694 | .map(|n| n.children.clone()) |
| 695 | .unwrap_or_default(); |
| 696 | for child in children { |
| 697 | self.paint_node(child, ui, theme); |
| 698 | } |
| 699 | } |
| 700 | |
| 701 | fn paint_node(&mut self, id: u32, ui: &mut Ui, theme: &Theme) { |
| 702 | let Some((tag, props)) = self |
| 703 | .slot(id) |
| 704 | .map(|n| (n.tag.clone(), Props(n.props.clone()))) |
| 705 | else { |
| 706 | return; |
| 707 | }; |
| 708 | let enabled = props.bool("sensitive", true); |
| 709 | // `:scroll-here` brings this node into view in whatever scroll area it |
| 710 | // sits in. It fires on every frame the prop is set, so a caller sets it |
| 711 | // for the moment of a jump and takes it off again — leaving it on would |
| 712 | // pin the area there and take scrolling away from the reader. |
| 713 | let scroll_here = props.bool("scroll-here", false); |
| 714 | let before = ui.cursor().top(); |
| 715 | self.with_width(&props, ui, |tree, ui| { |
| 716 | if enabled { |
| 717 | tree.paint_tag(id, &tag, &props, ui, theme); |
| 718 | } else { |
| 719 | // Scoped rather than per-widget: a dimmed container dims its |
| 720 | // whole subtree, which is what `:sensitive false` means |
| 721 | // everywhere else in glimmer. |
| 722 | ui.add_enabled_ui(false, |ui| tree.paint_tag(id, &tag, &props, ui, theme)); |
| 723 | } |
| 724 | }); |
| 725 | if scroll_here { |
| 726 | // Horizontally the rect is the visible width, not the node's own: |
| 727 | // a rect wider than the viewport is off-screen sideways as far as |
| 728 | // egui is concerned, so it scrolls across to centre it and the |
| 729 | // reader lands on a message with its left edge cut off. Already |
| 730 | // visible on that axis means only the vertical scroll happens. |
| 731 | let clip = ui.clip_rect(); |
| 732 | let rect = egui::Rect::from_min_max( |
| 733 | egui::pos2(clip.left(), before), |
| 734 | egui::pos2(clip.right(), ui.cursor().top()), |
| 735 | ); |
| 736 | ui.scroll_to_rect(rect, Some(Align::Center)); |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | /// Constrain `add` to the node's `:width-request`, when it has one. |
| 741 | /// |
| 742 | /// Immediate mode has no natural width for a field: an entry asks for |
| 743 | /// whatever is left, so an entry beside a button in an `:hbox` takes the |
| 744 | /// row and wraps the button onto the next line. This is how a caller says |
| 745 | /// otherwise. |
| 746 | fn with_width(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) { |
| 747 | let requested = props.num("width-request", 0.0) as f32; |
| 748 | let fill_height = props.bool("fill-height", false); |
| 749 | if requested <= 0.0 && !fill_height { |
| 750 | add(self, ui); |
| 751 | return; |
| 752 | } |
| 753 | let avail = ui.available_width().max(1.0); |
| 754 | let width = if requested > 0.0 { |
| 755 | requested.min(avail) |
| 756 | } else { |
| 757 | avail |
| 758 | }; |
| 759 | // The height is the row's, not zero: a region allocated with no height |
| 760 | // leaves the row measuring nothing at the moment the next widget is |
| 761 | // placed, so a button beside a text field lands at the row's top edge |
| 762 | // instead of beside it. |
| 763 | // |
| 764 | // A column of a split is the other case. Inside a row, "what is left" |
| 765 | // is the row's own height — one button tall at the moment the column |
| 766 | // is placed — so a pane asking for it is allocated a strip, and the |
| 767 | // scrolling list inside it gets no room. `:fill-height` measures |
| 768 | // against what is visible below the cursor instead, the way `:scroll` |
| 769 | // does: everything from here to the bottom of the window. |
| 770 | let height = if fill_height { |
| 771 | (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0) |
| 772 | } else { |
| 773 | ui.available_height().max(0.0) |
| 774 | }; |
| 775 | ui.allocate_ui_with_layout( |
| 776 | Vec2::new(width, height), |
| 777 | Layout::top_down(Align::Min), |
| 778 | |ui| { |
| 779 | ui.set_min_width(width); |
| 780 | ui.set_max_width(width); |
| 781 | add(self, ui); |
| 782 | }, |
| 783 | ); |
| 784 | } |
| 785 | |
| 786 | fn paint_tag(&mut self, id: u32, tag: &Tag, props: &Props, ui: &mut Ui, theme: &Theme) { |
| 787 | match tag { |
| 788 | // The root is the window itself: its children stack down the page. |
| 789 | // |
| 790 | // Its width is written back onto it, the way an entry writes back |
| 791 | // its text: a caller laying out against the window — one pane on a |
| 792 | // phone, two side by side on a desktop — has no other way to ask |
| 793 | // how much room it has, since nothing else here measures. |
| 794 | Tag::Window => { |
| 795 | let width = ui.available_width().max(0.0) as f64; |
| 796 | self.set(id, "window-width", Value::Num(width)); |
| 797 | self.paint_children(id, ui, theme) |
| 798 | } |
| 799 | |
| 800 | Tag::Box | Tag::Unknown(_) => { |
| 801 | let horizontal = props.str("orientation") == "horizontal"; |
| 802 | let spacing = props.num("spacing", theme.spacing.sm as f64) as f32; |
| 803 | self.with_margin(props, ui, |tree, ui| { |
| 804 | let axis = if horizontal { |
| 805 | Vec2::new(spacing, ui.spacing().item_spacing.y) |
| 806 | } else { |
| 807 | Vec2::new(ui.spacing().item_spacing.x, spacing) |
| 808 | }; |
| 809 | if horizontal { |
| 810 | // `:align :end` lays the row out from the right edge of |
| 811 | // the space it is given, which is how a trailing group |
| 812 | // — an action beside a message, a count beside a name — |
| 813 | // sits against the right of a row rather than trailing |
| 814 | // whatever came before it. |
| 815 | if props.str("align") == "end" { |
| 816 | // Nested in a row of its own: a right-to-left |
| 817 | // layout takes the height available to it, which |
| 818 | // in a column is everything below — every such row |
| 819 | // would be as tall as the rest of the screen, and |
| 820 | // the gaps would land between the rows above it. |
| 821 | ui.horizontal(|ui| { |
| 822 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 823 | ui.spacing_mut().item_spacing = axis; |
| 824 | tree.paint_children(id, ui, theme); |
| 825 | }); |
| 826 | }); |
| Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago | 827 | } else if props.str("align") == "center" { |
| 828 | // `:align :center` puts a row on the middle of the |
| 829 | // width rather than against its left edge — what a |
| 830 | // compose bar wants on a window wider than the |
| 831 | // line being typed into it. |
| 832 | // |
| 833 | // Indented rather than laid out centred: egui |
| 834 | // places a row as it goes, and knows how wide it |
| 835 | // came out only once it is painted. The width it |
| 836 | // measured last frame is what the indent is |
| 837 | // computed from, which is exact for a row whose |
| 838 | // contents keep their size and one frame late for |
| 839 | // one that changes. |
| 840 | let last = tree.row_widths.get(&id).copied().unwrap_or(0.0); |
| 841 | ui.horizontal(|ui| { |
| 842 | let avail = ui.available_width(); |
| 843 | ui.add_space(((avail - last) * 0.5).max(0.0)); |
| 844 | let left = ui.cursor().min.x; |
| 845 | ui.spacing_mut().item_spacing = axis; |
| 846 | tree.paint_children(id, ui, theme); |
| 847 | let width = (ui.min_rect().max.x - left).max(0.0); |
| 848 | tree.row_widths.insert(id, width); |
| 849 | }); |
| Catch up with vidya c90f8af nandi 19d ago | 850 | } else if props.bool("wrap", true) { |
| Bring vidya in cfd3e36 nandi 19d ago | 851 | ui.horizontal_wrapped(|ui| { |
| 852 | ui.spacing_mut().item_spacing = axis; |
| 853 | tree.paint_children(id, ui, theme); |
| Catch up with vidya c90f8af nandi 19d ago | 854 | }); |
| 855 | } else { |
| 856 | // `:wrap false` for a row whose children are |
| 857 | // columns rather than controls. A wrapped row moves |
| 858 | // a child that does not fit onto a line below, |
| 859 | // which is right for buttons beside a message and |
| 860 | // ruinous for the second half of a split: a pane |
| 861 | // asking for a few points more than are left is |
| 862 | // painted under the first one, off the bottom of |
| 863 | // the window, and reads as a pane that renders |
| 864 | // nothing at all. |
| 865 | ui.horizontal(|ui| { |
| 866 | ui.spacing_mut().item_spacing = axis; |
| 867 | tree.paint_children(id, ui, theme); |
| Bring vidya in cfd3e36 nandi 19d ago | 868 | }); |
| 869 | } |
| 870 | } else { |
| 871 | // `:align :center` puts a column's children on the |
| 872 | // middle of the width rather than against its left |
| 873 | // edge — what a picture on a screen of its own wants, |
| 874 | // and nothing a column of text ever does. |
| 875 | let cross = if props.str("align") == "center" { |
| 876 | Align::Center |
| 877 | } else { |
| 878 | Align::Min |
| 879 | }; |
| 880 | ui.with_layout(Layout::top_down(cross), |ui| { |
| 881 | ui.spacing_mut().item_spacing = axis; |
| 882 | tree.paint_children(id, ui, theme); |
| 883 | }); |
| 884 | } |
| 885 | }); |
| 886 | } |
| 887 | |
| 888 | // A scrolling column with page padding, optionally centred at a |
| 889 | // maximum width — the shell most Vidya apps put everything inside. |
| 890 | Tag::Page => { |
| 891 | let max_width = props.num("max-width", 0.0) as f32; |
| 892 | let pad = theme.spacing.page; |
| 893 | egui::ScrollArea::vertical() |
| 894 | .auto_shrink([false, false]) |
| 895 | .show(ui, |ui| { |
| 896 | egui::Frame::new() |
| 897 | .inner_margin(Margin::same(pad.clamp(0.0, 127.0) as i8)) |
| 898 | .show(ui, |ui| { |
| 899 | let avail = ui.available_width(); |
| 900 | let width = if max_width > 0.0 { |
| 901 | max_width.min(avail) |
| 902 | } else { |
| 903 | avail |
| 904 | }; |
| 905 | let indent = ((avail - width) * 0.5).max(0.0); |
| 906 | ui.horizontal(|ui| { |
| 907 | ui.add_space(indent); |
| 908 | ui.allocate_ui_with_layout( |
| 909 | Vec2::new(width, 0.0), |
| 910 | Layout::top_down(Align::Min), |
| 911 | |ui| { |
| 912 | ui.set_min_width(width); |
| 913 | ui.set_max_width(width); |
| 914 | vidya_core::vstack(ui, theme, |ui| { |
| 915 | self.paint_children(id, ui, theme); |
| 916 | }); |
| 917 | }, |
| 918 | ); |
| 919 | }); |
| 920 | }); |
| 921 | }); |
| 922 | } |
| 923 | |
| 924 | Tag::Scroll => { |
| 925 | let area = match props.str("orientation") { |
| 926 | "horizontal" => egui::ScrollArea::horizontal(), |
| 927 | "both" => egui::ScrollArea::both(), |
| 928 | _ => egui::ScrollArea::vertical(), |
| 929 | }; |
| 930 | // Without a bound a scroll area takes every point left in its |
| 931 | // parent, so anything after it — a compose bar under a message |
| 932 | // list — is pushed off the bottom. `:max-height` bounds it |
| 933 | // outright; `:reserve` bounds it by what it must leave behind, |
| 934 | // which is what a caller actually knows: the compose bar's |
| 935 | // height, not the window's. |
| 936 | let area = { |
| 937 | let reserve = props.num("reserve", 0.0) as f32; |
| 938 | let max_height = if reserve > 0.0 { |
| 939 | // Clamped against the clip rect as well as the layout's |
| 940 | // own idea of what is left: on Android the two differ |
| 941 | // once the soft keyboard takes the bottom of the |
| 942 | // screen, and it is the visible one that has to win or |
| 943 | // the row below the list is pushed off under the |
| 944 | // keyboard. |
| 945 | let visible = (ui.clip_rect().bottom() - ui.cursor().top()).max(0.0); |
| 946 | (ui.available_height().min(visible) - reserve).max(0.0) |
| 947 | } else { |
| 948 | props.num("max-height", 0.0) as f32 |
| 949 | }; |
| 950 | if max_height > 0.0 { |
| 951 | area.max_height(max_height) |
| 952 | } else { |
| 953 | area |
| 954 | } |
| 955 | }; |
| 956 | // Keyed by the node rather than by where it sits: egui derives |
| 957 | // a scroll area's id from its parent ui, so two areas that |
| 958 | // occupy the same place in the tree at different times — the |
| 959 | // message list and the picture that replaces the screen it is |
| 960 | // on — would otherwise share one offset, and the list would |
| 961 | // come back showing whatever the picture left behind. |
| 962 | // |
| 963 | // `:scroll-key` names an area that outlives its node instead. |
| 964 | // A node id is only as durable as the node: a list unmounted |
| 965 | // while another screen is up comes back as a new node, and a |
| 966 | // position keyed by that is a position thrown away. A caller |
| 967 | // that means "this same list again" says so with a name, and |
| 968 | // the reader returns to the line they left. |
| 969 | let key = { |
| 970 | let name = props.str("scroll-key"); |
| 971 | if name.is_empty() { |
| 972 | Id::new(("vidya_scroll", id)) |
| 973 | } else { |
| 974 | Id::new(("vidya_scroll_key", name)) |
| 975 | } |
| 976 | }; |
| 977 | let area = area.id_salt(key); |
| 978 | // A chat wants the newest line, not the oldest — except on a |
| 979 | // frame where something inside asked to be scrolled to. The |
| 980 | // two are the same control pulling opposite ways, and sticking |
| 981 | // wins every time it is asked, so a jump to an old message |
| 982 | // would land nowhere. |
| 983 | let sticks = props.bool("stick-to-bottom", false) && !self.wants_scroll_to(id); |
| 984 | let area = area.stick_to_bottom(sticks); |
| 985 | // `:scroll-to-bottom` is a number the caller bumps rather than |
| 986 | // a flag it sets: a flag would have to be cleared afterwards, |
| 987 | // and there is no frame in which the caller could do it. A |
| 988 | // value it has not seen before means "now". |
| 989 | let jump_key = key.with("jump"); |
| 990 | let jump = props.num("scroll-to-bottom", 0.0); |
| 991 | let jumped = ui.ctx().data(|d| d.get_temp::<f64>(jump_key)); |
| 992 | let jump_now = jump > 0.0 && jumped != Some(jump); |
| 993 | // The end is last frame's own maximum offset, kept for exactly |
| 994 | // this. Not f32::MAX — egui subtracts the viewport from what it |
| 995 | // is given, and MAX minus anything is still MAX, an offset the |
| 996 | // content can never reach: the area painted nothing and stayed |
| 997 | // that way. Not `scroll_to_rect` either, which a scroll area |
| 998 | // that has been scrolled away from ignores here. |
| 999 | let end_offset_key = key.with("end_offset"); |
| 1000 | let area = if jump_now { |
| 1001 | ui.ctx().data_mut(|d| d.insert_temp(jump_key, jump)); |
| 1002 | let end = ui |
| 1003 | .ctx() |
| 1004 | .data(|d| d.get_temp::<f32>(end_offset_key)) |
| 1005 | .unwrap_or(0.0); |
| 1006 | area.vertical_scroll_offset(end) |
| 1007 | } else { |
| 1008 | area |
| 1009 | }; |
| 1010 | // Hold the content to the viewport's width, as `:page` does, |
| 1011 | // so a wrapping child wraps at the visible edge. |
| 1012 | let viewport_width = ui.available_width(); |
| 1013 | let output = area.auto_shrink([false, false]).show(ui, |ui| { |
| 1014 | ui.set_max_width(viewport_width); |
| 1015 | self.paint_children(id, ui, theme); |
| 1016 | // The end asked for by scrolling to it, not by setting an |
| 1017 | // offset of f32::MAX: egui subtracts the viewport from |
| 1018 | // whatever it is given, and MAX minus anything is still |
| 1019 | // MAX — an offset the content can never reach, which left |
| 1020 | // the area painting nothing at all. |
| 1021 | |
| 1022 | }); |
| 1023 | |
| 1024 | // Say when the view leaves the end and when it comes back, so |
| 1025 | // a caller can offer the way back. Reported on change only: the |
| 1026 | // position itself changes every frame of a scroll, and an event |
| 1027 | // a frame is not news. |
| 1028 | // Within a line of the end counts as the end, and content |
| 1029 | // shorter than the viewport is always at it. |
| 1030 | let max_offset = (output.content_size.y - output.inner_rect.height()).max(0.0); |
| 1031 | // What `:scroll-to-bottom` will aim at next time it is asked. |
| 1032 | ui.ctx() |
| 1033 | .data_mut(|d| d.insert_temp(end_offset_key, max_offset)); |
| 1034 | let at_end = output.state.offset.y >= max_offset - 24.0; |
| 1035 | // Reaching the end is reported at once; leaving it has to hold |
| 1036 | // for a few frames first. A burst of arriving messages grows |
| 1037 | // the content faster than the offset follows it, and reporting |
| 1038 | // that honestly would blink "scrolled away" whenever a channel |
| 1039 | // is busy. |
| 1040 | let end_key = key.with("at_end"); |
| 1041 | let away_key = key.with("away_frames"); |
| 1042 | let away_frames = ui.ctx().data(|d| d.get_temp::<u32>(away_key)).unwrap_or(0); |
| 1043 | let away_frames = if at_end { 0 } else { away_frames.saturating_add(1) }; |
| 1044 | ui.ctx().data_mut(|d| d.insert_temp(away_key, away_frames)); |
| 1045 | |
| 1046 | let settled = if at_end { |
| 1047 | Some(true) |
| 1048 | } else if away_frames >= 3 { |
| 1049 | Some(false) |
| 1050 | } else { |
| 1051 | None |
| 1052 | }; |
| 1053 | if let Some(at_end) = settled { |
| 1054 | let was_at_end = ui.ctx().data(|d| d.get_temp::<bool>(end_key)); |
| 1055 | if was_at_end != Some(at_end) { |
| 1056 | ui.ctx().data_mut(|d| d.insert_temp(end_key, at_end)); |
| 1057 | // Only after the first report: the opening one would |
| 1058 | // arrive before the content has a height. |
| 1059 | if was_at_end.is_some() { |
| 1060 | self.emit( |
| 1061 | id, |
| 1062 | "change", |
| 1063 | if at_end { "end" } else { "away" }.to_owned(), |
| 1064 | if at_end { 1.0 } else { 0.0 }, |
| 1065 | ); |
| 1066 | } |
| 1067 | } |
| 1068 | } |
| 1069 | } |
| 1070 | |
| 1071 | Tag::Card => { |
| 1072 | vidya_core::card(ui, theme, |ui| self.paint_children(id, ui, theme)); |
| 1073 | } |
| 1074 | |
| 1075 | // A card with a heading — glimmer-tui's `:frame` label, in the |
| 1076 | // idiom this theme actually has for one. |
| 1077 | Tag::Frame => { |
| 1078 | let label = props.label(); |
| 1079 | vidya_core::card(ui, theme, |ui| { |
| 1080 | if !label.is_empty() { |
| 1081 | vidya_core::title_2(ui, theme, label); |
| 1082 | } |
| 1083 | self.paint_children(id, ui, theme); |
| 1084 | }); |
| 1085 | } |
| 1086 | |
| 1087 | Tag::Label => vidya_core::body(ui, theme, props.label()), |
| 1088 | |
| 1089 | // Body text that answers the pointer: the accent colour and the |
| 1090 | // hand cursor are the whole affordance, and the click is reported |
| 1091 | // like a button's so the caller decides what opening it means. |
| 1092 | Tag::Link => { |
| 1093 | let response = ui |
| 1094 | .add( |
| 1095 | egui::Label::new( |
| 1096 | egui::RichText::new(props.label()) |
| 1097 | .size(theme.type_scale.body) |
| 1098 | .color(theme.palette.accent), |
| 1099 | ) |
| 1100 | .wrap() |
| 1101 | .sense(egui::Sense::click()), |
| 1102 | ) |
| 1103 | .on_hover_cursor(egui::CursorIcon::PointingHand); |
| 1104 | if response.clicked() { |
| 1105 | self.emit(id, "click", props.label().to_owned(), 0.0); |
| 1106 | } |
| 1107 | } |
| 1108 | Tag::Title => vidya_core::title(ui, theme, props.label()), |
| 1109 | Tag::Title2 => vidya_core::title_2(ui, theme, props.label()), |
| 1110 | Tag::DimLabel => vidya_core::dim_label(ui, theme, props.label()), |
| 1111 | |
| 1112 | Tag::Button => { |
| 1113 | let kind = match props.str("kind") { |
| 1114 | "primary" => 1, |
| 1115 | "destructive" => 2, |
| 1116 | _ => 0, |
| 1117 | }; |
| 1118 | if crate::ui::button(ui, theme, props.label(), kind) { |
| 1119 | self.emit(id, "click", String::new(), 0.0); |
| 1120 | } |
| 1121 | } |
| 1122 | |
| 1123 | Tag::CheckButton => { |
| 1124 | let was = props.bool("active", false); |
| 1125 | let (now, changed) = crate::ui::checkbox(ui, theme, was, props.label()); |
| 1126 | if changed { |
| 1127 | // The widget does not own the value: the new state is |
| 1128 | // written back so a component that ignores `:on-toggled` |
| 1129 | // still tracks the click, and the handler decides whether |
| 1130 | // it survives the next render of `:active`. |
| 1131 | self.set(id, "active", Value::Bool(now)); |
| 1132 | self.emit(id, "toggled", String::new(), if now { 1.0 } else { 0.0 }); |
| 1133 | } |
| 1134 | } |
| 1135 | |
| 1136 | Tag::Entry => { |
| 1137 | let mut text = props.str("text").to_owned(); |
| 1138 | let placeholder = props.str("placeholder").to_owned(); |
| 1139 | let rows = props.num("rows", 4.0) as usize; |
| 1140 | let response = if props.bool("multiline", false) { |
| 1141 | vidya_core::text_field_multiline(ui, theme, &mut text, rows.max(1)) |
| 1142 | } else { |
| 1143 | crate::ui::text_field(ui, theme, &mut text, &placeholder) |
| 1144 | }; |
| 1145 | if text != props.str("text") { |
| 1146 | self.set(id, "text", Value::Str(text.clone())); |
| 1147 | self.emit(id, "change", text, 0.0); |
| 1148 | } |
| 1149 | if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { |
| 1150 | self.emit(id, "activate", String::new(), 0.0); |
| 1151 | } |
| 1152 | // A paste of something that is not text. egui turns Ctrl+V |
| 1153 | // into a `Paste` event carrying the clipboard's text, and a |
| 1154 | // clipboard holding a picture has none — so the keystroke |
| 1155 | // arrives as a key press with no paste behind it, and the |
| 1156 | // field would otherwise swallow it. Reported instead, for a |
| 1157 | // caller that has somewhere to put a picture; one that has not |
| 1158 | // ignores it and the keystroke stays as inert as it was. |
| 1159 | // |
| 1160 | // The clipboard is not read here: whether there is a picture |
| 1161 | // on it is answered by `vidya_clipboard_image_png`, and asking |
| 1162 | // twice would copy every pasted image for nothing. |
| 1163 | if response.has_focus() { |
| 1164 | let paste_without_text = ui.input(|i| { |
| 1165 | i.events.iter().any(|e| { |
| 1166 | matches!( |
| 1167 | e, |
| 1168 | egui::Event::Key { |
| 1169 | key: egui::Key::V, |
| 1170 | pressed: true, |
| 1171 | modifiers, |
| 1172 | .. |
| 1173 | } if modifiers.command |
| 1174 | ) |
| 1175 | }) && !i |
| 1176 | .events |
| 1177 | .iter() |
| 1178 | .any(|e| matches!(e, egui::Event::Paste(_))) |
| 1179 | }); |
| 1180 | if paste_without_text { |
| 1181 | self.emit(id, "paste-empty", String::new(), 0.0); |
| 1182 | } |
| 1183 | } |
| 1184 | } |
| 1185 | |
| 1186 | Tag::Separator => crate::ui::separator(ui), |
| 1187 | Tag::Spacer => crate::ui::gap(ui, props.num("size", theme.spacing.md as f64) as f32), |
| 1188 | Tag::Status => crate::ui::status(ui, theme, props.label(), props.bool("live", false)), |
| 1189 | |
| 1190 | Tag::Progress => { |
| 1191 | let value = props.num("value", 0.0) as f32; |
| 1192 | let mut bar = egui::ProgressBar::new(value.clamp(0.0, 1.0)); |
| 1193 | if !props.label().is_empty() { |
| 1194 | bar = bar.text(props.label()); |
| 1195 | } |
| 1196 | ui.add(bar); |
| 1197 | } |
| 1198 | |
| 1199 | // A picture from a file the caller has already fetched. Decoded |
| 1200 | // once and kept as a texture: the tree is walked every frame, and |
| 1201 | // decoding a PNG sixty times a second is not a thing to do. |
| 1202 | // Someone's face, or the next best thing. A chat wants one column |
| 1203 | // of them down the left, so this is a fixed square whatever the |
| 1204 | // picture's own proportions are, and there is always something to |
| 1205 | // draw: a name with no picture behind it becomes its initial on a |
| 1206 | // colour of its own, which keeps the column straight and still |
| 1207 | // tells one person from another at a glance. |
| 1208 | Tag::Avatar => { |
| 1209 | let size = props.num("size", 24.0) as f32; |
| 1210 | let label = props.label().to_owned(); |
| 1211 | let path = props.str("src").to_owned(); |
| 1212 | let (rect, response) = |
| 1213 | ui.allocate_exact_size(Vec2::splat(size), egui::Sense::click()); |
| 1214 | |
| 1215 | let texture = if path.is_empty() { |
| 1216 | None |
| 1217 | } else { |
| 1218 | self.texture(ui, &path) |
| 1219 | }; |
| 1220 | match texture { |
| 1221 | // A corner radius of half the side is a circle. |
| 1222 | Some(texture) => egui::Image::new(egui::load::SizedTexture::new( |
| 1223 | texture.id(), |
| 1224 | Vec2::splat(size), |
| 1225 | )) |
| 1226 | .corner_radius(size * 0.5) |
| 1227 | .paint_at(ui, rect), |
| 1228 | None => { |
| 1229 | let initial = label |
| 1230 | .trim_start_matches(['#', '&', '@', '+', '%', '~']) |
| 1231 | .chars() |
| 1232 | .next() |
| 1233 | .map(|c| c.to_uppercase().to_string()) |
| 1234 | .unwrap_or_else(|| "?".to_owned()); |
| 1235 | ui.painter() |
| 1236 | .circle_filled(rect.center(), size * 0.5, name_colour(&label, theme)); |
| 1237 | ui.painter().text( |
| 1238 | rect.center(), |
| 1239 | Align2::CENTER_CENTER, |
| 1240 | initial, |
| 1241 | FontId::proportional((size * 0.45).max(9.0)), |
| 1242 | theme.palette.accent_fg, |
| 1243 | ); |
| 1244 | } |
| 1245 | } |
| 1246 | if response.clicked() { |
| 1247 | self.emit(id, "click", label, 0.0); |
| 1248 | } |
| 1249 | } |
| 1250 | |
| 1251 | // A reaction chip: the emoji drawn from the Twemoji pack rather |
| 1252 | // than set as text, so it is the colour picture people expect and |
| 1253 | // not a monochrome glyph — or, where the font has no glyph at all, |
| 1254 | // tofu. `:count` rides beside it once more than one person is on |
| 1255 | // it, and `:mine` is what marks the ones you put there yourself. |
| 1256 | Tag::Reaction => { |
| 1257 | let emoji = props.str("emoji").to_owned(); |
| 1258 | let emoji = if emoji.is_empty() { |
| 1259 | props.label().to_owned() |
| 1260 | } else { |
| 1261 | emoji |
| 1262 | }; |
| 1263 | let count = props.num("count", 0.0).max(0.0) as usize; |
| 1264 | let mine = props.bool("mine", false); |
| 1265 | // `:size` is the glyph's, and the pill is sized from it. |
| 1266 | let size = props.num("size", 0.0) as f32; |
| 1267 | let response = if size > 0.0 { |
| 1268 | vidya_core::reaction_chip_sized(ui, theme, &emoji, count, mine, size) |
| 1269 | } else { |
| 1270 | vidya_core::reaction_chip(ui, theme, &emoji, count, mine) |
| 1271 | }; |
| 1272 | if response.clicked() { |
| 1273 | self.emit(id, "click", emoji, count as f64); |
| 1274 | } |
| 1275 | } |
| 1276 | |
| 1277 | Tag::Image => { |
| 1278 | // Two sources, one tag: a `src` is a file decoded once and |
| 1279 | // cached by its path, a `feed` is live pixels pushed in under |
| 1280 | // a name (`vidya_frame_rgba`) and re-uploaded as they arrive. |
| 1281 | // Everything downstream — fit, bounds, the click — is the same |
| 1282 | // for both, which is why this is a prop and not a second tag. |
| 1283 | let feed = props.str("feed").to_owned(); |
| 1284 | let path = props.str("src").to_owned(); |
| 1285 | let max_width = props.num("max-width", 0.0) as f32; |
| 1286 | let texture = if !feed.is_empty() { |
| 1287 | self.feed_texture(ui, &feed) |
| 1288 | } else if !path.is_empty() { |
| 1289 | self.texture(ui, &path) |
| 1290 | } else { |
| 1291 | return; |
| 1292 | }; |
| 1293 | let Some(texture) = texture else { |
| 1294 | // A file that will not decode is not worth a broken-image |
| 1295 | // glyph; the message text beside it already says what it |
| 1296 | // was meant to be. A feed that has had no frame yet is the |
| 1297 | // same: the tile appears when the first one lands. |
| 1298 | return; |
| 1299 | }; |
| 1300 | let size = texture.size_vec2(); |
| 1301 | |
| 1302 | // `:fit` gives the picture every point of the space it has |
| 1303 | // been handed and centres it in it — a picture on a screen of |
| 1304 | // its own, rather than one in a line of chat. It is the one |
| 1305 | // case that scales *up*: a picture opened to be looked at is |
| 1306 | // meant to fill the window, and how big the window is this |
| 1307 | // frame is something only this side knows. Everywhere else the |
| 1308 | // caller's `:max-height` bounds it and nothing is enlarged |
| 1309 | // past its own pixels. |
| 1310 | if props.bool("fit", false) { |
| 1311 | let space = ui.available_size(); |
| 1312 | if space.x <= 0.0 || space.y <= 0.0 || size.x <= 0.0 || size.y <= 0.0 { |
| 1313 | return; |
| 1314 | } |
| 1315 | let scale = (space.x / size.x).min(space.y / size.y); |
| 1316 | let (rect, response) = |
| 1317 | ui.allocate_exact_size(space, egui::Sense::click()); |
| 1318 | let painted = |
| 1319 | egui::Rect::from_center_size(rect.center(), size * scale); |
| 1320 | egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale)) |
| 1321 | .paint_at(ui, painted); |
| 1322 | if response.clicked() { |
| 1323 | self.emit(id, "click", String::new(), 0.0); |
| 1324 | } |
| 1325 | return; |
| 1326 | } |
| 1327 | |
| 1328 | let max_height = props.num("max-height", 240.0) as f32; |
| 1329 | let avail = if max_width > 0.0 { |
| 1330 | max_width.min(ui.available_width()) |
| 1331 | } else { |
| 1332 | ui.available_width() |
| 1333 | }; |
| Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago | 1334 | // A picture in a message is never enlarged past its own |
| 1335 | // pixels: blowing up a screenshot to fill a column makes it |
| 1336 | // worse, and the reader can open it if they want it bigger. |
| 1337 | // |
| 1338 | // `:upscale` says this one is different. A video tile is a |
| 1339 | // *slot* whose size the layout decided — how many people are |
| 1340 | // in the call, how big the window is — and a camera sending |
| 1341 | // 480 wide into a 900-point slot should fill it, the way every |
| 1342 | // other video surface does. Left off, the picture would sit at |
| 1343 | // its own size in the middle of a space reserved for it and |
| 1344 | // the layout would look broken. |
| 1345 | let scale = (avail / size.x).min(max_height / size.y); |
| 1346 | let scale = if props.bool("upscale", false) { |
| 1347 | scale |
| 1348 | } else { |
| 1349 | scale.min(1.0) |
| 1350 | }; |
| Bring vidya in cfd3e36 nandi 19d ago | 1351 | // Clickable whether or not the caller listens: the tree does |
| 1352 | // not know which nodes have handlers, and an unheard event |
| 1353 | // costs a queue slot. |
| 1354 | let response = ui |
| 1355 | .add( |
| 1356 | egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale)) |
| 1357 | .corner_radius(theme.spacing.radius_sm) |
| 1358 | .sense(egui::Sense::click()), |
| 1359 | ) |
| 1360 | .on_hover_cursor(egui::CursorIcon::PointingHand); |
| 1361 | if response.clicked() { |
| 1362 | self.emit(id, "click", String::new(), 0.0); |
| 1363 | } |
| 1364 | } |
| 1365 | |
| 1366 | Tag::Spinner => { |
| 1367 | ui.horizontal(|ui| { |
| 1368 | ui.add(egui::Spinner::new()); |
| 1369 | if !props.label().is_empty() { |
| 1370 | vidya_core::body(ui, theme, props.label()); |
| 1371 | } |
| 1372 | }); |
| 1373 | } |
| 1374 | } |
| 1375 | } |
| 1376 | |
| 1377 | /// Wrap `add` in the node's `:margin`, when it has one. |
| 1378 | fn with_margin(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) { |
| 1379 | // `:margin` sets all four sides; `:margin-top` and its siblings say |
| 1380 | // otherwise for one of them. A row that sits at the bottom of a screen |
| 1381 | // wants its space above it, not under it, and that is not a thing a |
| 1382 | // single number can express. |
| 1383 | let side = |key: &str| { |
| 1384 | props.num(key, props.num("margin", 0.0)).clamp(0.0, 127.0) as i8 |
| 1385 | }; |
| 1386 | let margin = Margin { |
| 1387 | left: side("margin-left"), |
| 1388 | right: side("margin-right"), |
| 1389 | top: side("margin-top"), |
| 1390 | bottom: side("margin-bottom"), |
| 1391 | }; |
| 1392 | if margin == Margin::ZERO { |
| 1393 | add(self, ui); |
| 1394 | return; |
| 1395 | } |
| 1396 | egui::Frame::new() |
| 1397 | .inner_margin(margin) |
| 1398 | .show(ui, |ui| add(self, ui)); |
| 1399 | } |
| 1400 | } |
| 1401 | |
| 1402 | /// Typed reads over a node's prop map, with the defaults each widget wants. |
| 1403 | struct Props(HashMap<String, Value>); |
| 1404 | |
| 1405 | impl Props { |
| 1406 | fn str(&self, key: &str) -> &str { |
| 1407 | match self.0.get(key) { |
| 1408 | Some(Value::Str(s)) => s, |
| 1409 | _ => "", |
| 1410 | } |
| 1411 | } |
| 1412 | |
| 1413 | fn num(&self, key: &str, default: f64) -> f64 { |
| 1414 | match self.0.get(key) { |
| 1415 | Some(Value::Num(n)) => *n, |
| 1416 | Some(Value::Bool(b)) => { |
| 1417 | if *b { |
| 1418 | 1.0 |
| 1419 | } else { |
| 1420 | 0.0 |
| 1421 | } |
| 1422 | } |
| 1423 | _ => default, |
| 1424 | } |
| 1425 | } |
| 1426 | |
| 1427 | fn bool(&self, key: &str, default: bool) -> bool { |
| 1428 | match self.0.get(key) { |
| 1429 | Some(Value::Bool(b)) => *b, |
| 1430 | Some(Value::Num(n)) => *n != 0.0, |
| 1431 | _ => default, |
| 1432 | } |
| 1433 | } |
| 1434 | |
| 1435 | /// `:label` is the family's name for a widget's text; `:text` is what a |
| 1436 | /// label is also allowed to use (and what an entry always uses). |
| 1437 | fn label(&self) -> &str { |
| 1438 | let label = self.str("label"); |
| 1439 | if label.is_empty() { |
| 1440 | self.str("text") |
| 1441 | } else { |
| 1442 | label |
| 1443 | } |
| 1444 | } |
| 1445 | } |
| 1446 | |
| 1447 | #[cfg(test)] |
| 1448 | mod tests { |
| 1449 | use super::*; |
| 1450 | |
| 1451 | fn kids(tree: &Tree, id: u32) -> Vec<u32> { |
| 1452 | tree.slot(id) |
| 1453 | .map(|n| n.children.clone()) |
| 1454 | .unwrap_or_default() |
| 1455 | } |
| 1456 | |
| 1457 | #[test] |
| 1458 | fn root_exists_and_is_a_window() { |
| 1459 | let tree = Tree::default(); |
| 1460 | assert!(tree.exists(tree.root())); |
| 1461 | assert_eq!(tree.slot(tree.root()).unwrap().tag, Tag::Window); |
| 1462 | } |
| 1463 | |
| 1464 | #[test] |
| 1465 | fn append_parents_once_even_when_reparenting() { |
| 1466 | let mut tree = Tree::default(); |
| 1467 | let a = tree.new_node("vbox"); |
| 1468 | let b = tree.new_node("hbox"); |
| 1469 | let leaf = tree.new_node("label"); |
| 1470 | tree.append(tree.root(), a); |
| 1471 | tree.append(tree.root(), b); |
| 1472 | |
| 1473 | tree.append(a, leaf); |
| 1474 | tree.append(b, leaf); |
| 1475 | assert_eq!(kids(&tree, a), vec![]); |
| 1476 | assert_eq!(kids(&tree, b), vec![leaf]); |
| 1477 | } |
| 1478 | |
| 1479 | #[test] |
| 1480 | fn a_cycle_is_refused() { |
| 1481 | let mut tree = Tree::default(); |
| 1482 | let outer = tree.new_node("vbox"); |
| 1483 | let inner = tree.new_node("vbox"); |
| 1484 | tree.append(tree.root(), outer); |
| 1485 | tree.append(outer, inner); |
| 1486 | assert!(!tree.append(inner, outer)); |
| 1487 | assert_eq!(kids(&tree, inner), vec![]); |
| 1488 | } |
| 1489 | |
| 1490 | #[test] |
| 1491 | fn remove_frees_the_whole_subtree_and_reuses_slots() { |
| 1492 | let mut tree = Tree::default(); |
| 1493 | let parent = tree.new_node("vbox"); |
| 1494 | let child = tree.new_node("label"); |
| 1495 | tree.append(tree.root(), parent); |
| 1496 | tree.append(parent, child); |
| 1497 | |
| 1498 | tree.remove(tree.root(), parent); |
| 1499 | assert!(!tree.exists(parent)); |
| 1500 | assert!(!tree.exists(child)); |
| 1501 | assert_eq!(tree.new_node("label"), child); |
| 1502 | } |
| 1503 | |
| 1504 | #[test] |
| 1505 | fn remove_ignores_a_child_of_someone_else() { |
| 1506 | let mut tree = Tree::default(); |
| 1507 | let a = tree.new_node("vbox"); |
| 1508 | let b = tree.new_node("vbox"); |
| 1509 | let leaf = tree.new_node("label"); |
| 1510 | tree.append(tree.root(), a); |
| 1511 | tree.append(tree.root(), b); |
| 1512 | tree.append(a, leaf); |
| 1513 | |
| 1514 | tree.remove(b, leaf); |
| 1515 | assert!(tree.exists(leaf)); |
| 1516 | assert_eq!(kids(&tree, a), vec![leaf]); |
| 1517 | } |
| 1518 | |
| 1519 | #[test] |
| 1520 | fn insert_after_reorders_in_both_directions() { |
| 1521 | let mut tree = Tree::default(); |
| 1522 | let parent = tree.new_node("vbox"); |
| 1523 | tree.append(tree.root(), parent); |
| 1524 | let a = tree.new_node("label"); |
| 1525 | let b = tree.new_node("label"); |
| 1526 | let c = tree.new_node("label"); |
| 1527 | for id in [a, b, c] { |
| 1528 | tree.append(parent, id); |
| 1529 | } |
| 1530 | |
| 1531 | // Move a forward, past two siblings. |
| 1532 | assert!(tree.insert_after(parent, a, c)); |
| 1533 | assert_eq!(kids(&tree, parent), vec![b, c, a]); |
| 1534 | // And back to the front. |
| 1535 | assert!(tree.insert_after(parent, a, 0)); |
| 1536 | assert_eq!(kids(&tree, parent), vec![a, b, c]); |
| 1537 | // A no-op move keeps the order it already had. |
| 1538 | assert!(tree.insert_after(parent, b, a)); |
| 1539 | assert_eq!(kids(&tree, parent), vec![a, b, c]); |
| 1540 | } |
| 1541 | |
| 1542 | #[test] |
| 1543 | fn replace_swaps_in_place_and_drops_the_old_node() { |
| 1544 | let mut tree = Tree::default(); |
| 1545 | let parent = tree.new_node("vbox"); |
| 1546 | tree.append(tree.root(), parent); |
| 1547 | let a = tree.new_node("label"); |
| 1548 | let b = tree.new_node("label"); |
| 1549 | let c = tree.new_node("button"); |
| 1550 | tree.append(parent, a); |
| 1551 | tree.append(parent, b); |
| 1552 | |
| 1553 | assert!(tree.replace(parent, a, c)); |
| 1554 | assert_eq!(kids(&tree, parent), vec![c, b]); |
| 1555 | assert!(!tree.exists(a)); |
| 1556 | } |
| 1557 | |
| 1558 | #[test] |
| 1559 | fn props_round_trip_and_clear() { |
| 1560 | let mut tree = Tree::default(); |
| 1561 | let id = tree.new_node("button"); |
| 1562 | tree.set(id, "label", Value::Str("Save".into())); |
| 1563 | tree.set(id, "value", Value::Num(0.5)); |
| 1564 | tree.set(id, "active", Value::Bool(true)); |
| 1565 | assert_eq!(tree.get(id, "label"), Some(&Value::Str("Save".into()))); |
| 1566 | assert_eq!(tree.get(id, "value"), Some(&Value::Num(0.5))); |
| 1567 | assert_eq!(tree.get(id, "active"), Some(&Value::Bool(true))); |
| 1568 | |
| 1569 | tree.clear_props(id); |
| 1570 | assert_eq!(tree.get(id, "label"), None); |
| 1571 | } |
| 1572 | |
| 1573 | #[test] |
| 1574 | fn events_drain_in_order_and_skip_removed_nodes() { |
| 1575 | let mut tree = Tree::default(); |
| 1576 | let a = tree.new_node("button"); |
| 1577 | let b = tree.new_node("button"); |
| 1578 | tree.append(tree.root(), a); |
| 1579 | tree.append(tree.root(), b); |
| 1580 | tree.emit(a, "click", String::new(), 0.0); |
| 1581 | tree.emit(b, "click", String::new(), 0.0); |
| 1582 | |
| 1583 | // Dropping `a` must drop the event still queued against it, or it would |
| 1584 | // be routed to a handler the caller has already forgotten. |
| 1585 | tree.remove(tree.root(), a); |
| 1586 | assert!(tree.poll()); |
| 1587 | assert_eq!(tree.current().unwrap().node, b); |
| 1588 | assert!(!tree.poll()); |
| 1589 | assert!(tree.current().is_none()); |
| 1590 | } |
| 1591 | |
| 1592 | #[test] |
| 1593 | fn unknown_tags_are_kept_as_boxes() { |
| 1594 | let mut tree = Tree::default(); |
| 1595 | let id = tree.new_node("carousel"); |
| 1596 | assert!(tree.exists(id)); |
| 1597 | assert_eq!(tree.slot(id).unwrap().tag, Tag::Unknown("carousel".to_owned())); |
| 1598 | } |
| 1599 | #[test] |
| 1600 | fn dump_is_hiccup_of_what_the_tree_holds() { |
| 1601 | let mut tree = Tree::default(); |
| 1602 | let root = tree.new_node("vbox"); |
| 1603 | tree.set(root, "spacing", Value::Num(8.0)); |
| 1604 | tree.set(root, "orientation", Value::Str("vertical".to_owned())); |
| 1605 | let button = tree.new_node("button"); |
| 1606 | tree.set(button, "label", Value::Str("go".to_owned())); |
| 1607 | tree.set(button, "sensitive", Value::Bool(false)); |
| 1608 | tree.append(root, button); |
| 1609 | |
| 1610 | assert_eq!( |
| 1611 | tree.dump(root), |
| 1612 | "[:box {:orientation \"vertical\" :spacing 8}\n \ |
| 1613 | [:button {:label \"go\" :sensitive false}]]" |
| 1614 | ); |
| 1615 | } |
| 1616 | |
| 1617 | #[test] |
| 1618 | fn dump_keeps_an_unknown_tag_and_escapes_a_string() { |
| 1619 | let mut tree = Tree::default(); |
| 1620 | let id = tree.new_node("carousel"); |
| 1621 | tree.set(id, "label", Value::Str("a \"quote\"\nand a line".to_owned())); |
| 1622 | assert_eq!( |
| 1623 | tree.dump(id), |
| 1624 | "[:carousel {:label \"a \\\"quote\\\"\\nand a line\"}]" |
| 1625 | ); |
| 1626 | assert_eq!(tree.dump(9999), "nil"); |
| 1627 | } |
| 1628 | |
| 1629 | #[test] |
| 1630 | fn a_frame_is_kept_for_the_paint_that_will_upload_it() { |
| 1631 | let mut tree = Tree::default(); |
| 1632 | assert!(tree.set_frame("nandi", 2, 2, &[0u8; 16])); |
| 1633 | assert!(tree.feeds["nandi"].pending.is_some()); |
| 1634 | |
| 1635 | // The newest frame is the only one worth painting: a second one |
| 1636 | // arriving before the first was drawn replaces it rather than queuing. |
| 1637 | assert!(tree.set_frame("nandi", 2, 2, &[7u8; 16])); |
| 1638 | let pending = tree.feeds["nandi"].pending.as_ref().unwrap(); |
| 1639 | assert_eq!(pending.size, [2, 2]); |
| 1640 | assert_eq!(tree.feeds.len(), 1); |
| 1641 | } |
| 1642 | |
| 1643 | #[test] |
| 1644 | fn a_frame_that_does_not_match_its_dimensions_is_refused() { |
| 1645 | let mut tree = Tree::default(); |
| 1646 | // Short of 2x2x4 — a capture path that changed resolution mid-stream |
| 1647 | // would otherwise paint the tail of the old buffer as the new one. |
| 1648 | assert!(!tree.set_frame("nandi", 2, 2, &[0u8; 15])); |
| 1649 | assert!(!tree.set_frame("nandi", 0, 2, &[])); |
| 1650 | assert!(!tree.set_frame("", 2, 2, &[0u8; 16])); |
| 1651 | assert!(tree.feeds.is_empty()); |
| 1652 | } |
| 1653 | |
| 1654 | #[test] |
| 1655 | fn dropping_a_feed_forgets_it() { |
| 1656 | let mut tree = Tree::default(); |
| 1657 | tree.set_frame("nandi", 1, 1, &[0u8; 4]); |
| 1658 | assert!(tree.drop_frame("nandi")); |
| 1659 | assert!(!tree.drop_frame("nandi")); |
| 1660 | assert!(tree.feeds.is_empty()); |
| 1661 | } |
| 1662 | } |