nandi/jolt-nativepublic Fork 0
e40b795acdf08fdae31fc3f95e29fcfc01e29eea
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 · 1819 lines · 76.8 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,
Draw an emoji in a line as a character, not as a tally 00d32dd nandi 16d ago71 /// 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 ago74 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
80impl 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 ago106 "emoji" => Self::Emoji,
Bring vidya in cfd3e36 nandi 19d ago107 "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 ago137 Self::Emoji => "emoji",
Bring vidya in cfd3e36 nandi 19d ago138 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)]
150pub 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}`.
160fn 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)]
192struct 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
201impl 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)]
209struct 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.
223pub 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 ago239 /// 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 ago247 /// 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 ago257 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.
266fn 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
287impl 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 }
Run the formatter over the tree 3e8c6f0 nandi 13d ago325 let image =
326 egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], rgba);
Bring vidya in cfd3e36 nandi 19d ago327 // Overwrites whatever had not been painted yet: the newest frame is
328 // the only one worth showing, and a backlog of stale ones is latency.
329 self.feeds.entry(key.to_owned()).or_default().pending = Some(image);
330 true
331 }
332
333 /// Forget a feed and release its texture. A call that ends leaves a tile
334 /// behind otherwise — the last frame of a participant who has gone.
335 pub fn drop_frame(&mut self, key: &str) -> bool {
336 self.feeds.remove(key).is_some()
337 }
338
339 /// The texture for a feed, uploading this paint's pending frame first.
340 fn feed_texture(&mut self, ui: &Ui, key: &str) -> Option<egui::TextureHandle> {
341 let feed = self.feeds.get_mut(key)?;
342 if let Some(image) = feed.pending.take() {
343 match feed.texture.as_mut() {
344 // `set` reuses the allocation when the size is unchanged,
345 // which is the whole point at video rates.
346 Some(texture) => texture.set(image, TextureOptions::LINEAR),
347 None => {
348 feed.texture = Some(ui.ctx().load_texture(
349 format!("vidya/tree/feed/{key}"),
350 image,
351 TextureOptions::LINEAR,
352 ))
353 }
354 }
355 }
356 feed.texture.clone()
357 }
358
359 fn texture(&mut self, ui: &Ui, path: &str) -> Option<egui::TextureHandle> {
360 if let Some(cached) = self.textures.get(path) {
361 return cached.clone();
362 }
363 let handle = std::fs::read(path)
364 .ok()
365 .and_then(|bytes| decode_png_rgba(&bytes))
366 .map(|image| {
367 ui.ctx()
368 .load_texture(format!("vidya/tree/{path}"), image, TextureOptions::LINEAR)
369 });
370 self.textures.insert(path.to_owned(), handle.clone());
371 handle
372 }
373}
374
375/// PNG bytes as an egui image. PNG alone: it is what the vendored decoder
376/// reads, and what the media this paints is served as.
377fn decode_png_rgba(bytes: &[u8]) -> Option<egui::ColorImage> {
378 let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
379 decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::ALPHA);
380 let mut reader = decoder.read_info().ok()?;
381 let mut buf = vec![0; reader.output_buffer_size()];
382 let info = reader.next_frame(&mut buf).ok()?;
383 let (w, h) = (info.width as usize, info.height as usize);
384 let raw = &buf[..info.buffer_size()];
385 let rgba: Vec<u8> = match info.color_type {
386 png::ColorType::Rgba => raw.to_vec(),
387 png::ColorType::Rgb => raw
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago388 .as_chunks::<3>()
389 .0
390 .iter()
Bring vidya in cfd3e36 nandi 19d ago391 .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
398impl 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 ago406 row_widths: HashMap::new(),
Let a face answer the pointer fa4ecdf nandi 19d ago407 hovered: None,
408 in_hover_panel: false,
Bring vidya in cfd3e36 nandi 19d ago409 pending: VecDeque::new(),
410 current: None,
411 };
412 tree.root = tree.new_node("window");
413 tree
414 }
415}
416
417impl 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) {
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago538 if self.slot(child).is_none_or(|n| n.parent != parent) {
Bring vidya in cfd3e36 nandi 19d ago539 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))
Clear the clippy backlog the new CI enforces 4956d1e nandi 13d ago569 .is_some_and(|i| i < index);
Bring vidya in cfd3e36 nandi 19d ago570 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 ago677 /// 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 ago703 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 ago813 //
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 ago820 let height = if fill_height {
Bound a fill-height region by :reserve 70072e8 nandi 19d ago821 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 ago823 } 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 ago878 } 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 ago901 } else if props.bool("wrap", true) {
Bring vidya in cfd3e36 nandi 19d ago902 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 ago905 });
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 ago919 });
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 // Say when the view leaves the end and when it comes back, so
1075 // a caller can offer the way back. Reported on change only: the
1076 // position itself changes every frame of a scroll, and an event
1077 // a frame is not news.
1078 // Within a line of the end counts as the end, and content
1079 // shorter than the viewport is always at it.
1080 let max_offset = (output.content_size.y - output.inner_rect.height()).max(0.0);
1081 // What `:scroll-to-bottom` will aim at next time it is asked.
1082 ui.ctx()
1083 .data_mut(|d| d.insert_temp(end_offset_key, max_offset));
1084 let at_end = output.state.offset.y >= max_offset - 24.0;
1085 // Reaching the end is reported at once; leaving it has to hold
1086 // for a few frames first. A burst of arriving messages grows
1087 // the content faster than the offset follows it, and reporting
1088 // that honestly would blink "scrolled away" whenever a channel
1089 // is busy.
1090 let end_key = key.with("at_end");
1091 let away_key = key.with("away_frames");
1092 let away_frames = ui.ctx().data(|d| d.get_temp::<u32>(away_key)).unwrap_or(0);
Run the formatter over the tree 3e8c6f0 nandi 13d ago1093 let away_frames = if at_end {
1094 0
1095 } else {
1096 away_frames.saturating_add(1)
1097 };
Bring vidya in cfd3e36 nandi 19d ago1098 ui.ctx().data_mut(|d| d.insert_temp(away_key, away_frames));
1099
1100 let settled = if at_end {
1101 Some(true)
1102 } else if away_frames >= 3 {
1103 Some(false)
1104 } else {
1105 None
1106 };
1107 if let Some(at_end) = settled {
1108 let was_at_end = ui.ctx().data(|d| d.get_temp::<bool>(end_key));
1109 if was_at_end != Some(at_end) {
1110 ui.ctx().data_mut(|d| d.insert_temp(end_key, at_end));
1111 // Only after the first report: the opening one would
1112 // arrive before the content has a height.
1113 if was_at_end.is_some() {
1114 self.emit(
1115 id,
1116 "change",
1117 if at_end { "end" } else { "away" }.to_owned(),
1118 if at_end { 1.0 } else { 0.0 },
1119 );
1120 }
1121 }
1122 }
1123 }
1124
1125 Tag::Card => {
1126 vidya_core::card(ui, theme, |ui| self.paint_children(id, ui, theme));
1127 }
1128
1129 // A card with a heading — glimmer-tui's `:frame` label, in the
1130 // idiom this theme actually has for one.
1131 Tag::Frame => {
1132 let label = props.label();
1133 vidya_core::card(ui, theme, |ui| {
1134 if !label.is_empty() {
1135 vidya_core::title_2(ui, theme, label);
1136 }
1137 self.paint_children(id, ui, theme);
1138 });
1139 }
1140
1141 Tag::Label => vidya_core::body(ui, theme, props.label()),
1142
1143 // Body text that answers the pointer: the accent colour and the
1144 // hand cursor are the whole affordance, and the click is reported
1145 // like a button's so the caller decides what opening it means.
1146 Tag::Link => {
1147 let response = ui
1148 .add(
1149 egui::Label::new(
1150 egui::RichText::new(props.label())
1151 .size(theme.type_scale.body)
1152 .color(theme.palette.accent),
1153 )
1154 .wrap()
1155 .sense(egui::Sense::click()),
1156 )
1157 .on_hover_cursor(egui::CursorIcon::PointingHand);
1158 if response.clicked() {
1159 self.emit(id, "click", props.label().to_owned(), 0.0);
1160 }
1161 }
1162 Tag::Title => vidya_core::title(ui, theme, props.label()),
1163 Tag::Title2 => vidya_core::title_2(ui, theme, props.label()),
1164 Tag::DimLabel => vidya_core::dim_label(ui, theme, props.label()),
1165
1166 Tag::Button => {
1167 let kind = match props.str("kind") {
1168 "primary" => 1,
1169 "destructive" => 2,
1170 _ => 0,
1171 };
1172 if crate::ui::button(ui, theme, props.label(), kind) {
1173 self.emit(id, "click", String::new(), 0.0);
1174 }
1175 }
1176
1177 Tag::CheckButton => {
1178 let was = props.bool("active", false);
1179 let (now, changed) = crate::ui::checkbox(ui, theme, was, props.label());
1180 if changed {
1181 // The widget does not own the value: the new state is
1182 // written back so a component that ignores `:on-toggled`
1183 // still tracks the click, and the handler decides whether
1184 // it survives the next render of `:active`.
1185 self.set(id, "active", Value::Bool(now));
1186 self.emit(id, "toggled", String::new(), if now { 1.0 } else { 0.0 });
1187 }
1188 }
1189
1190 Tag::Entry => {
1191 let mut text = props.str("text").to_owned();
1192 let placeholder = props.str("placeholder").to_owned();
1193 let rows = props.num("rows", 4.0) as usize;
1194 let response = if props.bool("multiline", false) {
1195 vidya_core::text_field_multiline(ui, theme, &mut text, rows.max(1))
1196 } else {
1197 crate::ui::text_field(ui, theme, &mut text, &placeholder)
1198 };
1199 if text != props.str("text") {
1200 self.set(id, "text", Value::Str(text.clone()));
1201 self.emit(id, "change", text, 0.0);
1202 }
1203 if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
1204 self.emit(id, "activate", String::new(), 0.0);
1205 }
1206 // A paste of something that is not text. egui turns Ctrl+V
1207 // into a `Paste` event carrying the clipboard's text, and a
1208 // clipboard holding a picture has none — so the keystroke
1209 // arrives as a key press with no paste behind it, and the
1210 // field would otherwise swallow it. Reported instead, for a
1211 // caller that has somewhere to put a picture; one that has not
1212 // ignores it and the keystroke stays as inert as it was.
1213 //
1214 // The clipboard is not read here: whether there is a picture
1215 // on it is answered by `vidya_clipboard_image_png`, and asking
1216 // twice would copy every pasted image for nothing.
1217 if response.has_focus() {
1218 let paste_without_text = ui.input(|i| {
1219 i.events.iter().any(|e| {
1220 matches!(
1221 e,
1222 egui::Event::Key {
1223 key: egui::Key::V,
1224 pressed: true,
1225 modifiers,
1226 ..
1227 } if modifiers.command
1228 )
Run the formatter over the tree 3e8c6f0 nandi 13d ago1229 }) && !i.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))
Bring vidya in cfd3e36 nandi 19d ago1230 });
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());
Run the formatter over the tree 3e8c6f0 nandi 13d ago1286 ui.painter().circle_filled(
1287 rect.center(),
1288 size * 0.5,
1289 name_colour(&label, theme),
1290 );
Bring vidya in cfd3e36 nandi 19d ago1291 ui.painter().text(
1292 rect.center(),
1293 Align2::CENTER_CENTER,
1294 initial,
1295 FontId::proportional((size * 0.45).max(9.0)),
1296 theme.palette.accent_fg,
1297 );
1298 }
1299 }
1300 if response.clicked() {
1301 self.emit(id, "click", label, 0.0);
1302 }
Let a face answer the pointer fa4ecdf nandi 19d ago1303 // The face answers the pointer as well as the tap. A hover
1304 // says so once, when it starts, and once when it ends —
1305 // per-frame events would be a flood, and the caller only
1306 // wants the two edges. What it does with them is its own
1307 // business; the usual answer is to render children here,
1308 // which are painted as the hover's own panel below.
1309 self.track_hover(id, &response);
1310 // A hovered avatar with children shows them beside the
1311 // pointer: a profile card is a thing the tree can already
1312 // describe, and this is the only layer above the page that
1313 // exists to put one in.
1314 if response.hovered() && !self.in_hover_panel && self.has_children(id) {
1315 self.in_hover_panel = true;
1316 response.show_tooltip_ui(|ui| {
1317 ui.set_max_width(320.0);
1318 self.paint_children(id, ui, theme);
1319 });
1320 self.in_hover_panel = false;
1321 }
Bring vidya in cfd3e36 nandi 19d ago1322 }
1323
1324 // A reaction chip: the emoji drawn from the Twemoji pack rather
1325 // than set as text, so it is the colour picture people expect and
1326 // not a monochrome glyph — or, where the font has no glyph at all,
1327 // tofu. `:count` rides beside it once more than one person is on
1328 // it, and `:mine` is what marks the ones you put there yourself.
1329 Tag::Reaction => {
1330 let emoji = props.str("emoji").to_owned();
1331 let emoji = if emoji.is_empty() {
1332 props.label().to_owned()
1333 } else {
1334 emoji
1335 };
1336 let count = props.num("count", 0.0).max(0.0) as usize;
1337 let mine = props.bool("mine", false);
1338 // `:size` is the glyph's, and the pill is sized from it.
1339 let size = props.num("size", 0.0) as f32;
1340 let response = if size > 0.0 {
1341 vidya_core::reaction_chip_sized(ui, theme, &emoji, count, mine, size)
1342 } else {
1343 vidya_core::reaction_chip(ui, theme, &emoji, count, mine)
1344 };
1345 if response.clicked() {
1346 self.emit(id, "click", emoji, count as f64);
1347 }
Let a reaction pill answer the pointer, as a face does c71fc59 nandi 18d ago1348 // A pill answers the pointer the way a face does: the two
1349 // edges of a hover, and children painted beside the pointer
1350 // while it rests. A reaction is a tally, and who is in it is
1351 // the thing the tally leaves out.
1352 self.track_hover(id, &response);
1353 if response.hovered() && !self.in_hover_panel && self.has_children(id) {
1354 self.in_hover_panel = true;
1355 response.show_tooltip_ui(|ui| {
1356 ui.set_max_width(320.0);
1357 self.paint_children(id, ui, theme);
1358 });
1359 self.in_hover_panel = false;
1360 }
Bring vidya in cfd3e36 nandi 19d ago1361 }
1362
Draw an emoji in a line as a character, not as a tally 00d32dd nandi 16d ago1363 Tag::Emoji => {
1364 // A glyph the text font cannot set, drawn from the pack and
1365 // put in the line as if it were a word. `Reaction` draws the
1366 // same picture, but a reaction is a tally: it wears a pill, it
1367 // answers the pointer, and it names the people in it on hover.
1368 // An emoji in a sentence is none of those things — it is a
1369 // character — so this allocates the square and paints, and
1370 // stops there.
1371 let emoji = props.str("emoji").to_owned();
1372 let emoji = if emoji.is_empty() {
1373 props.label().to_owned()
1374 } else {
1375 emoji
1376 };
1377 // Body size by default, because the words either side are what
1378 // it has to sit level with.
1379 let size = props.num("size", theme.type_scale.body as f64) as f32;
1380 vidya_core::emoji_icon(ui, theme, &emoji, size);
1381 }
1382
Bring vidya in cfd3e36 nandi 19d ago1383 Tag::Image => {
1384 // Two sources, one tag: a `src` is a file decoded once and
1385 // cached by its path, a `feed` is live pixels pushed in under
1386 // a name (`vidya_frame_rgba`) and re-uploaded as they arrive.
1387 // Everything downstream — fit, bounds, the click — is the same
1388 // for both, which is why this is a prop and not a second tag.
1389 let feed = props.str("feed").to_owned();
1390 let path = props.str("src").to_owned();
1391 let max_width = props.num("max-width", 0.0) as f32;
1392 let texture = if !feed.is_empty() {
1393 self.feed_texture(ui, &feed)
1394 } else if !path.is_empty() {
1395 self.texture(ui, &path)
1396 } else {
1397 return;
1398 };
1399 let Some(texture) = texture else {
1400 // A file that will not decode is not worth a broken-image
1401 // glyph; the message text beside it already says what it
1402 // was meant to be. A feed that has had no frame yet is the
1403 // same: the tile appears when the first one lands.
1404 return;
1405 };
1406 let size = texture.size_vec2();
1407
1408 // `:fit` gives the picture every point of the space it has
1409 // been handed and centres it in it — a picture on a screen of
1410 // its own, rather than one in a line of chat. It is the one
1411 // case that scales *up*: a picture opened to be looked at is
1412 // meant to fill the window, and how big the window is this
1413 // frame is something only this side knows. Everywhere else the
1414 // caller's `:max-height` bounds it and nothing is enlarged
1415 // past its own pixels.
1416 if props.bool("fit", false) {
1417 let space = ui.available_size();
1418 if space.x <= 0.0 || space.y <= 0.0 || size.x <= 0.0 || size.y <= 0.0 {
1419 return;
1420 }
1421 let scale = (space.x / size.x).min(space.y / size.y);
Run the formatter over the tree 3e8c6f0 nandi 13d ago1422 let (rect, response) = ui.allocate_exact_size(space, egui::Sense::click());
1423 let painted = egui::Rect::from_center_size(rect.center(), size * scale);
Bring vidya in cfd3e36 nandi 19d ago1424 egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale))
1425 .paint_at(ui, painted);
1426 if response.clicked() {
1427 self.emit(id, "click", String::new(), 0.0);
1428 }
1429 return;
1430 }
1431
1432 let max_height = props.num("max-height", 240.0) as f32;
1433 let avail = if max_width > 0.0 {
1434 max_width.min(ui.available_width())
1435 } else {
1436 ui.available_width()
1437 };
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago1438 // A picture in a message is never enlarged past its own
1439 // pixels: blowing up a screenshot to fill a column makes it
1440 // worse, and the reader can open it if they want it bigger.
1441 //
1442 // `:upscale` says this one is different. A video tile is a
1443 // *slot* whose size the layout decided — how many people are
1444 // in the call, how big the window is — and a camera sending
1445 // 480 wide into a 900-point slot should fill it, the way every
1446 // other video surface does. Left off, the picture would sit at
1447 // its own size in the middle of a space reserved for it and
1448 // the layout would look broken.
1449 let scale = (avail / size.x).min(max_height / size.y);
1450 let scale = if props.bool("upscale", false) {
1451 scale
1452 } else {
1453 scale.min(1.0)
1454 };
Bring vidya in cfd3e36 nandi 19d ago1455 // Clickable whether or not the caller listens: the tree does
1456 // not know which nodes have handlers, and an unheard event
1457 // costs a queue slot.
1458 let response = ui
1459 .add(
1460 egui::Image::new(egui::load::SizedTexture::new(texture.id(), size * scale))
1461 .corner_radius(theme.spacing.radius_sm)
1462 .sense(egui::Sense::click()),
1463 )
1464 .on_hover_cursor(egui::CursorIcon::PointingHand);
1465 if response.clicked() {
1466 self.emit(id, "click", String::new(), 0.0);
1467 }
1468 }
1469
1470 Tag::Spinner => {
1471 ui.horizontal(|ui| {
1472 ui.add(egui::Spinner::new());
1473 if !props.label().is_empty() {
1474 vidya_core::body(ui, theme, props.label());
1475 }
1476 });
1477 }
1478 }
1479 }
1480
1481 /// Wrap `add` in the node's `:margin`, when it has one.
1482 fn with_margin(&mut self, props: &Props, ui: &mut Ui, add: impl FnOnce(&mut Self, &mut Ui)) {
1483 // `:margin` sets all four sides; `:margin-top` and its siblings say
1484 // otherwise for one of them. A row that sits at the bottom of a screen
1485 // wants its space above it, not under it, and that is not a thing a
1486 // single number can express.
Run the formatter over the tree 3e8c6f0 nandi 13d ago1487 let side = |key: &str| props.num(key, props.num("margin", 0.0)).clamp(0.0, 127.0) as i8;
Bring vidya in cfd3e36 nandi 19d ago1488 let margin = Margin {
1489 left: side("margin-left"),
1490 right: side("margin-right"),
1491 top: side("margin-top"),
1492 bottom: side("margin-bottom"),
1493 };
1494 if margin == Margin::ZERO {
1495 add(self, ui);
1496 return;
1497 }
1498 egui::Frame::new()
1499 .inner_margin(margin)
1500 .show(ui, |ui| add(self, ui));
1501 }
1502}
1503
1504/// Typed reads over a node's prop map, with the defaults each widget wants.
1505struct Props(HashMap<String, Value>);
1506
1507impl Props {
1508 fn str(&self, key: &str) -> &str {
1509 match self.0.get(key) {
1510 Some(Value::Str(s)) => s,
1511 _ => "",
1512 }
1513 }
1514
1515 fn num(&self, key: &str, default: f64) -> f64 {
1516 match self.0.get(key) {
1517 Some(Value::Num(n)) => *n,
1518 Some(Value::Bool(b)) => {
1519 if *b {
1520 1.0
1521 } else {
1522 0.0
1523 }
1524 }
1525 _ => default,
1526 }
1527 }
1528
1529 fn bool(&self, key: &str, default: bool) -> bool {
1530 match self.0.get(key) {
1531 Some(Value::Bool(b)) => *b,
1532 Some(Value::Num(n)) => *n != 0.0,
1533 _ => default,
1534 }
1535 }
1536
1537 /// `:label` is the family's name for a widget's text; `:text` is what a
1538 /// label is also allowed to use (and what an entry always uses).
1539 fn label(&self) -> &str {
1540 let label = self.str("label");
1541 if label.is_empty() {
1542 self.str("text")
1543 } else {
1544 label
1545 }
1546 }
1547}
1548
1549#[cfg(test)]
1550mod tests {
1551 use super::*;
1552
1553 fn kids(tree: &Tree, id: u32) -> Vec<u32> {
1554 tree.slot(id)
1555 .map(|n| n.children.clone())
1556 .unwrap_or_default()
1557 }
1558
Let a reaction pill answer the pointer, as a face does c71fc59 nandi 18d ago1559 /// The pointer resting on a reaction pill says so, the way it does on a
1560 /// face: a chip is where a tally is, and who is in the tally is what a
1561 /// hover is for.
1562 #[test]
1563 fn hovering_a_reaction_emits_hover() {
1564 let mut tree = Tree::default();
1565 let pill = tree.new_node("reaction");
1566 tree.set(pill, "emoji", Value::Str("\u{1f44d}".to_owned()));
1567 tree.set(pill, "count", Value::Num(2.0));
1568 tree.append(tree.root(), pill);
1569 let theme = Theme::dark();
1570 let ctx = egui::Context::default();
1571
1572 let mut input = egui::RawInput::default();
Run the formatter over the tree 3e8c6f0 nandi 13d ago1573 input
1574 .events
1575 .push(egui::Event::PointerMoved(egui::pos2(20.0, 20.0)));
Let a reaction pill answer the pointer, as a face does c71fc59 nandi 18d ago1576 let _ = ctx.run(input.clone(), |ctx| {
1577 egui::CentralPanel::default().show(ctx, |ui| tree.paint(ui, &theme));
1578 });
1579 let _ = ctx.run(input, |ctx| {
1580 egui::CentralPanel::default().show(ctx, |ui| tree.paint(ui, &theme));
1581 });
1582
1583 let mut names = Vec::new();
1584 while tree.poll() {
1585 names.push(tree.current().unwrap().name);
1586 }
1587 assert!(names.contains(&"hover"), "no hover from a pill: {names:?}");
1588
1589 // And a pill with children paints them beside the pointer. A window
1590 // node is the probe: painting one writes its width back, so the prop
1591 // appearing is the card having been drawn.
1592 let card = tree.new_node("window");
1593 tree.append(pill, card);
1594 let mut input = egui::RawInput::default();
Run the formatter over the tree 3e8c6f0 nandi 13d ago1595 input
1596 .events
1597 .push(egui::Event::PointerMoved(egui::pos2(20.0, 20.0)));
Let a reaction pill answer the pointer, as a face does c71fc59 nandi 18d ago1598 let _ = ctx.run(input, |ctx| {
1599 egui::CentralPanel::default().show(ctx, |ui| tree.paint(ui, &theme));
1600 });
1601 assert!(
1602 tree.get(card, "window-width").is_some(),
1603 "a hovered pill did not paint its card"
1604 );
1605 }
1606
Bring vidya in cfd3e36 nandi 19d ago1607 #[test]
1608 fn root_exists_and_is_a_window() {
1609 let tree = Tree::default();
1610 assert!(tree.exists(tree.root()));
1611 assert_eq!(tree.slot(tree.root()).unwrap().tag, Tag::Window);
1612 }
1613
1614 #[test]
1615 fn append_parents_once_even_when_reparenting() {
1616 let mut tree = Tree::default();
1617 let a = tree.new_node("vbox");
1618 let b = tree.new_node("hbox");
1619 let leaf = tree.new_node("label");
1620 tree.append(tree.root(), a);
1621 tree.append(tree.root(), b);
1622
1623 tree.append(a, leaf);
1624 tree.append(b, leaf);
1625 assert_eq!(kids(&tree, a), vec![]);
1626 assert_eq!(kids(&tree, b), vec![leaf]);
1627 }
1628
1629 #[test]
1630 fn a_cycle_is_refused() {
1631 let mut tree = Tree::default();
1632 let outer = tree.new_node("vbox");
1633 let inner = tree.new_node("vbox");
1634 tree.append(tree.root(), outer);
1635 tree.append(outer, inner);
1636 assert!(!tree.append(inner, outer));
1637 assert_eq!(kids(&tree, inner), vec![]);
1638 }
1639
1640 #[test]
1641 fn remove_frees_the_whole_subtree_and_reuses_slots() {
1642 let mut tree = Tree::default();
1643 let parent = tree.new_node("vbox");
1644 let child = tree.new_node("label");
1645 tree.append(tree.root(), parent);
1646 tree.append(parent, child);
1647
1648 tree.remove(tree.root(), parent);
1649 assert!(!tree.exists(parent));
1650 assert!(!tree.exists(child));
1651 assert_eq!(tree.new_node("label"), child);
1652 }
1653
1654 #[test]
1655 fn remove_ignores_a_child_of_someone_else() {
1656 let mut tree = Tree::default();
1657 let a = tree.new_node("vbox");
1658 let b = tree.new_node("vbox");
1659 let leaf = tree.new_node("label");
1660 tree.append(tree.root(), a);
1661 tree.append(tree.root(), b);
1662 tree.append(a, leaf);
1663
1664 tree.remove(b, leaf);
1665 assert!(tree.exists(leaf));
1666 assert_eq!(kids(&tree, a), vec![leaf]);
1667 }
1668
1669 #[test]
1670 fn insert_after_reorders_in_both_directions() {
1671 let mut tree = Tree::default();
1672 let parent = tree.new_node("vbox");
1673 tree.append(tree.root(), parent);
1674 let a = tree.new_node("label");
1675 let b = tree.new_node("label");
1676 let c = tree.new_node("label");
1677 for id in [a, b, c] {
1678 tree.append(parent, id);
1679 }
1680
1681 // Move a forward, past two siblings.
1682 assert!(tree.insert_after(parent, a, c));
1683 assert_eq!(kids(&tree, parent), vec![b, c, a]);
1684 // And back to the front.
1685 assert!(tree.insert_after(parent, a, 0));
1686 assert_eq!(kids(&tree, parent), vec![a, b, c]);
1687 // A no-op move keeps the order it already had.
1688 assert!(tree.insert_after(parent, b, a));
1689 assert_eq!(kids(&tree, parent), vec![a, b, c]);
1690 }
1691
1692 #[test]
1693 fn replace_swaps_in_place_and_drops_the_old_node() {
1694 let mut tree = Tree::default();
1695 let parent = tree.new_node("vbox");
1696 tree.append(tree.root(), parent);
1697 let a = tree.new_node("label");
1698 let b = tree.new_node("label");
1699 let c = tree.new_node("button");
1700 tree.append(parent, a);
1701 tree.append(parent, b);
1702
1703 assert!(tree.replace(parent, a, c));
1704 assert_eq!(kids(&tree, parent), vec![c, b]);
1705 assert!(!tree.exists(a));
1706 }
1707
1708 #[test]
1709 fn props_round_trip_and_clear() {
1710 let mut tree = Tree::default();
1711 let id = tree.new_node("button");
1712 tree.set(id, "label", Value::Str("Save".into()));
1713 tree.set(id, "value", Value::Num(0.5));
1714 tree.set(id, "active", Value::Bool(true));
1715 assert_eq!(tree.get(id, "label"), Some(&Value::Str("Save".into())));
1716 assert_eq!(tree.get(id, "value"), Some(&Value::Num(0.5)));
1717 assert_eq!(tree.get(id, "active"), Some(&Value::Bool(true)));
1718
1719 tree.clear_props(id);
1720 assert_eq!(tree.get(id, "label"), None);
1721 }
1722
1723 #[test]
1724 fn events_drain_in_order_and_skip_removed_nodes() {
1725 let mut tree = Tree::default();
1726 let a = tree.new_node("button");
1727 let b = tree.new_node("button");
1728 tree.append(tree.root(), a);
1729 tree.append(tree.root(), b);
1730 tree.emit(a, "click", String::new(), 0.0);
1731 tree.emit(b, "click", String::new(), 0.0);
1732
1733 // Dropping `a` must drop the event still queued against it, or it would
1734 // be routed to a handler the caller has already forgotten.
1735 tree.remove(tree.root(), a);
1736 assert!(tree.poll());
1737 assert_eq!(tree.current().unwrap().node, b);
1738 assert!(!tree.poll());
1739 assert!(tree.current().is_none());
1740 }
1741
1742 #[test]
1743 fn unknown_tags_are_kept_as_boxes() {
1744 let mut tree = Tree::default();
1745 let id = tree.new_node("carousel");
1746 assert!(tree.exists(id));
Run the formatter over the tree 3e8c6f0 nandi 13d ago1747 assert_eq!(
1748 tree.slot(id).unwrap().tag,
1749 Tag::Unknown("carousel".to_owned())
1750 );
Bring vidya in cfd3e36 nandi 19d ago1751 }
1752 #[test]
1753 fn dump_is_hiccup_of_what_the_tree_holds() {
1754 let mut tree = Tree::default();
1755 let root = tree.new_node("vbox");
1756 tree.set(root, "spacing", Value::Num(8.0));
1757 tree.set(root, "orientation", Value::Str("vertical".to_owned()));
1758 let button = tree.new_node("button");
1759 tree.set(button, "label", Value::Str("go".to_owned()));
1760 tree.set(button, "sensitive", Value::Bool(false));
1761 tree.append(root, button);
1762
1763 assert_eq!(
1764 tree.dump(root),
1765 "[:box {:orientation \"vertical\" :spacing 8}\n \
1766 [:button {:label \"go\" :sensitive false}]]"
1767 );
1768 }
1769
1770 #[test]
1771 fn dump_keeps_an_unknown_tag_and_escapes_a_string() {
1772 let mut tree = Tree::default();
1773 let id = tree.new_node("carousel");
Run the formatter over the tree 3e8c6f0 nandi 13d ago1774 tree.set(
1775 id,
1776 "label",
1777 Value::Str("a \"quote\"\nand a line".to_owned()),
1778 );
Bring vidya in cfd3e36 nandi 19d ago1779 assert_eq!(
1780 tree.dump(id),
1781 "[:carousel {:label \"a \\\"quote\\\"\\nand a line\"}]"
1782 );
1783 assert_eq!(tree.dump(9999), "nil");
1784 }
1785
1786 #[test]
1787 fn a_frame_is_kept_for_the_paint_that_will_upload_it() {
1788 let mut tree = Tree::default();
1789 assert!(tree.set_frame("nandi", 2, 2, &[0u8; 16]));
1790 assert!(tree.feeds["nandi"].pending.is_some());
1791
1792 // The newest frame is the only one worth painting: a second one
1793 // arriving before the first was drawn replaces it rather than queuing.
1794 assert!(tree.set_frame("nandi", 2, 2, &[7u8; 16]));
1795 let pending = tree.feeds["nandi"].pending.as_ref().unwrap();
1796 assert_eq!(pending.size, [2, 2]);
1797 assert_eq!(tree.feeds.len(), 1);
1798 }
1799
1800 #[test]
1801 fn a_frame_that_does_not_match_its_dimensions_is_refused() {
1802 let mut tree = Tree::default();
1803 // Short of 2x2x4 — a capture path that changed resolution mid-stream
1804 // would otherwise paint the tail of the old buffer as the new one.
1805 assert!(!tree.set_frame("nandi", 2, 2, &[0u8; 15]));
1806 assert!(!tree.set_frame("nandi", 0, 2, &[]));
1807 assert!(!tree.set_frame("", 2, 2, &[0u8; 16]));
1808 assert!(tree.feeds.is_empty());
1809 }
1810
1811 #[test]
1812 fn dropping_a_feed_forgets_it() {
1813 let mut tree = Tree::default();
1814 tree.set_frame("nandi", 1, 1, &[0u8; 4]);
1815 assert!(tree.drop_frame("nandi"));
1816 assert!(!tree.drop_frame("nandi"));
1817 assert!(tree.feeds.is_empty());
1818 }
1819}