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

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

tree.rs · 1662 lines · 69.5 KBRust Blame HistoryRaw
Bring vidya in cfd3e36 nandi 19d ago1//! 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
30use std::collections::HashMap;
31use std::collections::VecDeque;
32
33use egui::{Align, Align2, Color32, FontId, Id, Layout, Margin, TextureOptions, Ui, Vec2};
34use 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)]
39pub 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)]
49pub 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
77impl 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)]
145pub 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}`.
155fn 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)]
187struct 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
196impl 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)]
204struct 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.
218pub 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 ago234 /// 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 ago242 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.
251fn 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
272impl 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.
364fn 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
383impl 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 ago391 row_widths: HashMap::new(),
Bring vidya in cfd3e36 nandi 19d ago392 pending: VecDeque::new(),
393 current: None,
394 };
395 tree.root = tree.new_node("window");
396 tree
397 }
398}
399
400impl 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 ago827 } 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 ago850 } else if props.bool("wrap", true) {
Bring vidya in cfd3e36 nandi 19d ago851 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 ago854 });
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 ago868 });
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 ago1334 // 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 ago1351 // 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.
1403struct Props(HashMap<String, Value>);
1404
1405impl 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)]
1448mod 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}