1use std::collections::{HashMap, HashSet};
15
16use serde_json::{Map, Value};
17
18use crate::{
19 Error,
20 ui::{
21 Node, View,
22 node::{Handler, Trigger},
23 },
24 wire::{Event, Op, TextMode, WireNode, kind},
25};
26
27pub const REGIONS: [&str; 3] = ["main", "dock", "layer"];
29
30#[derive(Clone, Debug, PartialEq)]
32pub struct Tree {
33 id: String,
35 kind: String,
37 props: Map<String, Value>,
39 children: Vec<Self>,
41}
42
43impl Tree {
44 pub fn id(&self) -> &str {
46 &self.id
47 }
48
49 pub fn kind(&self) -> &str {
51 &self.kind
52 }
53
54 pub const fn props(&self) -> &Map<String, Value> {
56 &self.props
57 }
58
59 pub fn children(&self) -> &[Self] {
61 &self.children
62 }
63
64 pub fn wire(&self) -> WireNode {
66 WireNode {
67 id: self.id.clone(),
68 k: self.kind.clone(),
69 p: self.props.clone(),
70 c: self.children.iter().map(Self::wire).collect(),
71 }
72 }
73
74 fn build<M>(id: String, node: Node<M>, routes: &mut Routes<M>) -> Result<Self, Error> {
76 let Node { kind, mut props, children: mut nodes, handlers, slots } = node;
77 let slotted = |c: &Node<M>| {
81 c.props
82 .get("key")
83 .and_then(Value::as_str)
84 .is_some_and(|k| slots.contains(&k))
85 };
86 nodes.sort_by_key(slotted);
87 for slot in &slots {
88 props.insert((*slot).into(), Value::String(format!("{id}.{slot}")));
89 }
90 if !handlers.is_empty() {
91 let found = attach(&id, &mut props, handlers);
92 routes.by_id.insert(id.clone(), found);
93 }
94 let mut seen = HashSet::with_capacity(nodes.len());
95 let mut children = Vec::with_capacity(nodes.len());
96 for (i, child) in nodes.into_iter().enumerate() {
97 let key = match child.props.get("key") {
98 Some(Value::String(k)) => k.clone(),
99 Some(Value::Number(n)) => n.to_string(),
100 _ => i.to_string(),
101 };
102 let child_id = format!("{id}.{key}");
103 if !seen.insert(child_id.clone()) {
104 return Err(Error::DuplicateId { path: id, key });
105 }
106 children.push(Self::build(child_id, child, routes)?);
107 }
108 Ok(Self { id, kind, props, children })
109 }
110
111 fn from_json(id: String, node: &Value) -> Result<Self, Error> {
113 let Some(obj) = node.as_object() else {
114 return Err(Error::InvalidView(format!("{id}: a node must be a table, got {node}")));
115 };
116 let Some(kind) = obj.get("k").and_then(Value::as_str) else {
117 return Err(Error::InvalidView(format!("{id}: a node needs a string `k`")));
118 };
119 let props = match obj.get("p") {
120 None | Some(Value::Null) => Map::new(),
121 Some(Value::Object(p)) => p.clone(),
122 Some(other) => {
123 return Err(Error::InvalidView(format!("{id}: `p` must be a table, got {other}")));
124 },
125 };
126 let nodes: &[Value] = match obj.get("c") {
127 None | Some(Value::Null) => &[],
128 Some(Value::Array(c)) => c,
129 Some(Value::Object(c)) if c.is_empty() => &[],
130 Some(other) => {
131 return Err(Error::InvalidView(format!("{id}: `c` must be a list, got {other}")));
132 },
133 };
134 let mut seen = HashSet::with_capacity(nodes.len());
135 let mut children = Vec::with_capacity(nodes.len());
136 for (i, child) in nodes.iter().enumerate() {
137 let key = match child.get("p").and_then(|p| p.get("key")) {
138 Some(Value::String(k)) => k.clone(),
139 Some(Value::Number(n)) => n.to_string(),
140 _ => i.to_string(),
141 };
142 let child_id = format!("{id}.{key}");
143 if !seen.insert(child_id.clone()) {
144 return Err(Error::DuplicateId { path: id, key });
145 }
146 children.push(Self::from_json(child_id, child)?);
147 }
148 Ok(Self { id, kind: kind.to_owned(), props, children })
149 }
150
151 fn has_text(&self) -> bool {
153 kind::has_text(&self.kind)
154 }
155}
156
157enum Route {
159 Act(String),
161 Ev(&'static str),
163}
164
165pub(crate) struct Routes<M> {
167 by_id: HashMap<String, Vec<(Route, Handler<M>)>>,
169}
170
171impl<M> Default for Routes<M> {
172 fn default() -> Self {
173 Self { by_id: HashMap::new() }
174 }
175}
176
177impl<M> Routes<M> {
178 fn at(&self, id: &str, event: &Event, matches: impl Fn(&Route) -> bool) -> Option<M> {
180 self
181 .by_id
182 .get(id)?
183 .iter()
184 .filter(|(r, _)| matches(r))
185 .find_map(|(_, h)| h(event))
186 }
187
188 pub(crate) fn route(&self, event: &Event) -> Option<M> {
191 let id = event.id()?;
192 match event {
193 Event::Action(a) => self.at(id, event, |r| {
194 matches!(r, Route::Act(n) if *n == a.act || n.split_once('=')
195 .is_some_and(|(act, value)| {
196 act == a.act && a.value.as_ref().and_then(Value::as_str) == Some(value)
197 }))
198 }),
199 Event::Select(s) | Event::Activate(s) => {
200 let name = event.name();
201 let is = |r: &Route| matches!(r, Route::Ev(e) if *e == name);
202 (s.item != id)
203 .then(|| self.at(&s.item, event, is))
204 .flatten()
205 .or_else(|| self.at(id, event, is))
206 },
207 _ => {
208 let name = event.name();
209 self.at(id, event, |r| matches!(r, Route::Ev(e) if *e == name))
210 },
211 }
212 }
213}
214
215fn attach<M>(
218 id: &str,
219 props: &mut Map<String, Value>,
220 handlers: Vec<(Trigger, Handler<M>)>,
221) -> Vec<(Route, Handler<M>)> {
222 let mut out = Vec::with_capacity(handlers.len());
223 for (trigger, handler) in handlers {
224 let route = match trigger {
225 Trigger::Action(act) => Route::Act(act),
226 Trigger::Event(ev) => Route::Ev(ev),
227 pointer => {
228 let actions = props
229 .entry("actions")
230 .or_insert_with(|| Value::Object(Map::new()));
231 let Value::Object(actions) = actions else {
232 tracing::warn!(id, "`actions` is not an object here; pointer handler ignored");
233 continue;
234 };
235 match pointer {
236 Trigger::Click | Trigger::Dblclick => {
237 let gesture = if pointer == Trigger::Click {
238 "click"
239 } else {
240 "dblclick"
241 };
242 let act = actions
243 .entry(gesture)
244 .or_insert_with(|| Value::String(gesture.into()))
245 .as_str()
246 .unwrap_or(gesture)
247 .to_owned();
248 Route::Act(act)
249 },
250 Trigger::Menu(act) => {
251 let menu = actions
252 .entry("menu")
253 .or_insert_with(|| Value::Array(Vec::new()));
254 if let Value::Array(menu) = menu
255 && !menu
256 .iter()
257 .any(|m| m.as_str().or_else(|| m.get("id")?.as_str()) == Some(act.as_str()))
258 {
259 menu.push(Value::String(act.clone()));
260 }
261 Route::Act(act)
262 },
263 Trigger::Action(_) | Trigger::Event(_) => unreachable!("matched above"),
264 }
265 },
266 };
267 out.push((route, handler));
268 }
269 out
270}
271
272#[derive(Clone, Debug, Default, PartialEq)]
274pub struct Doc {
275 regions: [Option<Tree>; 3],
277}
278
279impl Doc {
280 pub fn new() -> Self {
282 Self::default()
283 }
284
285 pub fn from_view<M>(view: View<M>) -> Result<Self, Error> {
290 Self::build(view).map(|(doc, _)| doc)
291 }
292
293 pub fn from_json(value: &Value) -> Result<Self, Error> {
306 let mut regions = [None, None, None];
307 match value {
308 Value::Null => {},
309 Value::Object(obj) => {
310 if let Some(extra) = obj.keys().find(|k| !REGIONS.contains(&k.as_str())) {
311 return Err(Error::InvalidView(format!(
312 "view: unknown region {extra:?} (main, dock or layer)"
313 )));
314 }
315 for (slot, name) in regions.iter_mut().zip(REGIONS) {
316 if let Some(node) = obj.get(name).filter(|n| !n.is_null()) {
317 *slot = Some(Tree::from_json(name.to_owned(), node)?);
318 }
319 }
320 },
321 other => {
322 return Err(Error::InvalidView(format!(
323 "view: expected {{main?, dock?, layer?}}, got {other}"
324 )));
325 },
326 }
327 Ok(Self { regions })
328 }
329
330 pub(crate) fn build<M>(view: View<M>) -> Result<(Self, Routes<M>), Error> {
332 let mut routes = Routes::default();
333 let mut regions = [None, None, None];
334 let View { main, dock, layer } = view;
335 for ((slot, name), node) in regions.iter_mut().zip(REGIONS).zip([main, dock, layer]) {
336 if let Some(node) = node {
337 *slot = Some(Tree::build(name.to_owned(), node, &mut routes)?);
338 }
339 }
340 Ok((Self { regions }, routes))
341 }
342
343 pub(crate) fn retain_main(&mut self) {
345 self.regions[1] = None;
346 self.regions[2] = None;
347 }
348
349 pub const fn regions(&self) -> &[Option<Tree>; 3] {
351 &self.regions
352 }
353
354 pub fn ops(&self, next: &Self, surface: &str) -> Vec<Op> {
356 let mut ops = Vec::new();
357 for (old, new) in self.regions.iter().zip(&next.regions) {
358 match (old, new) {
359 (None, None) => {},
360 (Some(old), None) => ops.push(Op::Del { id: old.id.clone() }),
361 (None, Some(new)) => ops.push(add(new, surface, None)),
362 (Some(old), Some(new)) if old.kind != new.kind => {
363 ops.push(Op::Del { id: old.id.clone() });
364 ops.push(add(new, surface, None));
365 },
366 (Some(old), Some(new)) => diff(old, new, &mut ops),
367 }
368 }
369 ops
370 }
371}
372
373fn add(tree: &Tree, parent: &str, before: Option<&str>) -> Op {
375 Op::Add { parent: parent.to_owned(), before: before.map(str::to_owned), node: tree.wire() }
376}
377
378fn diff(old: &Tree, new: &Tree, ops: &mut Vec<Op>) {
380 let text = new.has_text();
381 let mut set = Map::new();
382 for (key, value) in &new.props {
383 if text && key == "text" {
384 continue;
385 }
386 if old.props.get(key) != Some(value) {
387 set.insert(key.clone(), value.clone());
388 }
389 }
390 for key in old.props.keys() {
391 let primary = text && key == "text";
392 if !primary && !new.props.contains_key(key) {
393 set.insert(key.clone(), Value::Null);
394 }
395 }
396 if !set.is_empty() {
397 ops.push(Op::Set { id: new.id.clone(), props: set });
398 }
399 if text {
400 let before = old.props.get("text");
401 let after = new.props.get("text");
402 if before != after {
403 match (before.and_then(Value::as_str), after.and_then(Value::as_str)) {
404 (Some(a), Some(b)) if !a.is_empty() && b.starts_with(a) => ops.push(Op::Text {
405 id: new.id.clone(),
406 mode: TextMode::Append,
407 text: b[a.len()..].to_owned(),
408 }),
409 (_, Some(b)) => ops.push(Op::Text {
410 id: new.id.clone(),
411 mode: TextMode::Replace,
412 text: b.to_owned(),
413 }),
414 (_, None) => {
415 let mut props = Map::new();
416 props.insert("text".into(), Value::Null);
417 ops.push(Op::Set { id: new.id.clone(), props });
418 },
419 }
420 }
421 }
422 children(old, new, ops);
423}
424
425fn children(old: &Tree, new: &Tree, ops: &mut Vec<Op>) {
429 let before: HashMap<&str, (usize, &Tree)> = old
430 .children
431 .iter()
432 .enumerate()
433 .map(|(i, c)| (c.id.as_str(), (i, c)))
434 .collect();
435 let kept: HashSet<&str> = new.children.iter().map(|c| c.id.as_str()).collect();
436 for child in &old.children {
437 if !kept.contains(child.id.as_str()) {
438 ops.push(Op::Del { id: child.id.clone() });
439 }
440 }
441 let same: Vec<(usize, usize)> = new
444 .children
445 .iter()
446 .enumerate()
447 .filter_map(|(at, c)| {
448 before
449 .get(c.id.as_str())
450 .filter(|(_, o)| o.kind == c.kind)
451 .map(|(i, _)| (at, *i))
452 })
453 .collect();
454 let stay: HashSet<usize> = rising(&same.iter().map(|(_, i)| *i).collect::<Vec<_>>())
455 .into_iter()
456 .map(|k| same[k].0)
457 .collect();
458 let mut next: Option<&str> = None;
459 for (at, child) in new.children.iter().enumerate().rev() {
460 match before.get(child.id.as_str()) {
461 None => ops.push(add(child, &new.id, next)),
462 Some((_, o)) if o.kind != child.kind => {
463 ops.push(Op::Del { id: child.id.clone() });
464 ops.push(add(child, &new.id, next));
465 },
466 Some((_, o)) => {
467 if !stay.contains(&at) {
468 ops.push(Op::Move {
469 id: child.id.clone(),
470 parent: new.id.clone(),
471 before: next.map(str::to_owned),
472 });
473 }
474 diff(o, child, ops);
475 },
476 }
477 next = Some(child.id.as_str());
478 }
479}
480
481fn rising(seq: &[usize]) -> Vec<usize> {
483 let mut tails: Vec<usize> = Vec::new();
485 let mut prev: Vec<Option<usize>> = vec![None; seq.len()];
486 for (i, &v) in seq.iter().enumerate() {
487 let at = tails.partition_point(|&t| seq[t] < v);
488 prev[i] = at.checked_sub(1).map(|p| tails[p]);
489 if at == tails.len() {
490 tails.push(i);
491 } else {
492 tails[at] = i;
493 }
494 }
495 let mut out = Vec::with_capacity(tails.len());
496 let mut cur = tails.last().copied();
497 while let Some(i) = cur {
498 out.push(i);
499 cur = prev[i];
500 }
501 out.reverse();
502 out
503}
504
505#[cfg(test)]
506mod tests {
507 use serde_json::json;
508
509 use super::*;
510 use crate::ui;
511
512 #[test]
513 fn json_empty_luau_tables_are_empty_children() {
514 let bare = Doc::from_json(&json!({"main": {"k": "col"}})).unwrap();
515 for children in [Value::Null, json!({}), json!([])] {
516 let doc = Doc::from_json(&json!({
517 "main": {"k": "col", "p": null, "c": children},
518 "dock": null, "layer": null,
519 }))
520 .unwrap();
521 assert_eq!(doc, bare);
522 assert!(doc.ops(&bare, "p").is_empty());
523 }
524 let nested = Doc::from_json(&json!({
525 "main": {"k": "col", "c": [{"k": "col", "p": {}, "c": {}}]},
526 }))
527 .unwrap();
528 assert!(
529 nested.regions()[0].as_ref().unwrap().children()[0]
530 .children()
531 .is_empty()
532 );
533 assert_eq!(Doc::from_json(&Value::Null).unwrap(), Doc::new());
534 }
535
536 #[test]
537 fn json_rejects_unknown_regions_even_when_null() {
538 for node in [Value::Null, json!({"k": "col"})] {
539 let error = Doc::from_json(&json!({"side": node})).unwrap_err();
540 assert_eq!(error.to_string(), "view: unknown region \"side\" (main, dock or layer)");
541 }
542 for value in [json!(1), json!([]), json!("main")] {
543 assert!(matches!(Doc::from_json(&value), Err(Error::InvalidView(_))));
544 }
545 }
546
547 #[test]
548 fn json_rejects_malformed_nodes_with_paths() {
549 for (node, message) in [
550 (json!(1), "a node must be a table, got 1"),
551 (json!([]), "a node must be a table, got []"),
552 (json!({}), "a node needs a string `k`"),
553 (json!({"k": false}), "a node needs a string `k`"),
554 (json!({"k": "col", "p": []}), "`p` must be a table, got []"),
555 (json!({"k": "col", "p": 1}), "`p` must be a table, got 1"),
556 (json!({"k": "col", "c": {"bad": 1}}), "`c` must be a list, got {\"bad\":1}"),
557 (json!({"k": "col", "c": false}), "`c` must be a list, got false"),
558 (json!({"k": "col", "c": "bad"}), "`c` must be a list, got \"bad\""),
559 ] {
560 let error = Doc::from_json(&json!({"main": node})).unwrap_err();
561 assert_eq!(error.to_string(), format!("main: {message}"));
562 let error = Doc::from_json(&json!({
563 "main": {"k": "col", "c": [{"k": "col", "p": {"key": "nested"}, "c": [node]}]},
564 }))
565 .unwrap_err();
566 assert_eq!(error.to_string(), format!("main.nested.0: {message}"));
567 }
568 }
569
570 #[test]
571 fn slotted_nodes_become_keyed_children_sent_by_id() {
572 let view: View = View::new().main([crate::ui::card()
573 .head(crate::ui::text("Jobs"))
574 .end(crate::ui::button("Refresh"))
575 .end(crate::ui::button("Reload"))
576 .child(crate::ui::text("body"))
577 .key("panel")]);
578 let doc = Doc::from_view(view).unwrap();
579 let card = &doc.regions()[0].as_ref().unwrap().children()[0];
580 assert_eq!(card.props()["head"], json!("main.panel.head"));
581 assert_eq!(card.props()["end"], json!("main.panel.end"));
582 let ids: Vec<&str> = card.children().iter().map(Tree::id).collect();
583 assert_eq!(ids, ["main.panel.0", "main.panel.head", "main.panel.end"]);
584 assert_eq!(card.children()[2].props()["text"], json!("Reload"));
585 }
586
587 #[test]
588 fn text_after_a_slotted_head_drops_the_child() {
589 let card = crate::ui::card::<()>()
590 .head(crate::ui::text("x"))
591 .head("Plain");
592 let doc = Doc::from_view(View::new().main([card])).unwrap();
593 let card = &doc.regions()[0].as_ref().unwrap().children()[0];
594 assert_eq!(card.props()["head"], json!("Plain"));
595 assert!(card.children().is_empty());
596 }
597
598 #[test]
599 fn duplicate_child_errors_name_parent_and_key() {
600 for (keys, path, key) in [
601 ([json!("a"), json!("a")], "main", "a"),
602 ([json!(7), json!("7")], "main", "7"),
603 ([json!("a.b"), json!("a.b")], "main", "a.b"),
604 ] {
605 let children = keys.map(|key| json!({"k": "text", "p": {"key": key}}));
606 let node = json!({"k": "col", "c": children});
607 let error = Doc::from_json(&json!({"main": node})).unwrap_err();
608 assert_eq!(error.to_string(), format!("{path}: duplicate child key {key:?}"));
609 assert!(matches!(error, Error::DuplicateId { path: p, key: k } if p == path && k == key));
610 let error = Doc::from_json(&json!({
611 "main": {"k": "col", "c": [node]},
612 }))
613 .unwrap_err();
614 assert_eq!(error.to_string(), format!("main.0: duplicate child key {key:?}"));
615 }
616 let view = View::<()>::new().main([ui::text("a").key("x"), ui::text("b").key("x")]);
617 assert_eq!(Doc::from_view(view).unwrap_err().to_string(), "main: duplicate child key \"x\"");
618 }
619
620 #[test]
621 fn integer_keys_derive_decimal_ids() {
622 let doc = Doc::from_json(&json!({
623 "main": {"k": "col", "c": [
624 {"k": "text", "p": {"key": -42}},
625 {"k": "text", "p": {"key": u64::MAX}},
626 ]},
627 }))
628 .unwrap();
629 let children = doc.regions()[0].as_ref().unwrap().children();
630 assert_eq!(children[0].id(), "main.-42");
631 assert_eq!(children[1].id(), "main.18446744073709551615");
632 }
633
634 #[test]
635 fn pointer_actions_match_both_name_and_value() {
636 let (doc, routes) = Doc::build(View::from(
637 ui::text("open")
638 .prop("actions", json!({"click": "open=one=two", "dblclick": "open=other"}))
639 .on_click(1)
640 .on_dblclick(2)
641 .on_action("fallback", 3),
642 ))
643 .unwrap();
644 assert_eq!(
645 doc.regions()[0].as_ref().unwrap().children()[0].props()["actions"],
646 json!({"click": "open=one=two", "dblclick": "open=other"})
647 );
648 for (act, value, expected) in [
649 ("open", Some("one=two"), Some(1)),
650 ("open", Some("other"), Some(2)),
651 ("open", Some("wrong"), None),
652 ("open", None, None),
653 ("wrong", Some("one=two"), None),
654 ("fallback", Some("anything"), Some(3)),
655 ] {
656 let mut raw = json!({"ev": "action", "sf": "s1", "id": "main.0", "act": act});
657 if let Some(value) = value {
658 raw["value"] = json!(value);
659 }
660 let event = Event::decode(raw.as_object().unwrap().clone());
661 assert_eq!(routes.route(&event), expected);
662 }
663 }
664
665 #[test]
666 fn retaining_main_drops_only_transient_regions() {
667 let mut doc = Doc::from_view(
668 View::<()>::new()
669 .main([ui::text("kept")])
670 .dock([ui::text("draft")])
671 .layer([ui::text("dialog")]),
672 )
673 .unwrap();
674 let main = doc.regions()[0].clone();
675 doc.retain_main();
676 assert_eq!(doc.regions(), &[main, None, None]);
677 let next = Doc::from_view(View::<()>::new().main([ui::text("kept")])).unwrap();
678 assert!(doc.ops(&next, "s1").is_empty());
679 }
680}