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