Skip to main content

tern_sdk/
reconcile.rs

1//! Layer 4: reconcile. A view diffed into frame ops with derived ids.
2//! Tern's plugin worker uses this reconciler directly.
3//!
4//! A region root's id is its region's name; a child's is `<parent>.<key>`,
5//! where `key` is its `key` prop (a string, or an integer in decimal), else
6//! its index among its siblings. Two siblings with one id reject the whole
7//! view. The same id and kind become one `set` of the changed props (removed
8//! ones `null`) and, for text kinds, a `text` op (`append` when the new text
9//! extends a non-empty old one); a kind change is `del` + `add`; children
10//! are deleted, then placed from the last back (added, re-added or moved
11//! before their next sibling), each kept child diffed right after it is
12//! placed.
13
14use 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
27/// The regions of a surface, in the order they are added.
28pub const REGIONS: [&str; 3] = ["main", "dock", "layer"];
29
30/// One node of a view with its id assigned.
31#[derive(Clone, Debug, PartialEq)]
32pub struct Tree {
33	/// `parent.(key or index)`, or the region name at the top.
34	id:       String,
35	/// The kind.
36	kind:     String,
37	/// Props as the view gave them (with handler actions merged in).
38	props:    Map<String, Value>,
39	/// Children in order.
40	children: Vec<Self>,
41}
42
43impl Tree {
44	/// The node's id.
45	pub fn id(&self) -> &str {
46		&self.id
47	}
48
49	/// The node's kind.
50	pub fn kind(&self) -> &str {
51		&self.kind
52	}
53
54	/// The node's props.
55	pub const fn props(&self) -> &Map<String, Value> {
56		&self.props
57	}
58
59	/// The node's children.
60	pub fn children(&self) -> &[Self] {
61		&self.children
62	}
63
64	/// The node as an `add` op carries it, ids at every level.
65	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	/// Builds the tree of `node` under `id`, collecting its handlers.
75	fn build<M>(id: String, node: Node<M>, routes: &mut Routes<M>) -> Result<Self, Error> {
76		let Node { kind, mut props, children: nodes, handlers } = node;
77		if !handlers.is_empty() {
78			let found = attach(&id, &mut props, handlers);
79			routes.by_id.insert(id.clone(), found);
80		}
81		let mut seen = HashSet::with_capacity(nodes.len());
82		let mut children = Vec::with_capacity(nodes.len());
83		for (i, child) in nodes.into_iter().enumerate() {
84			let key = match child.props.get("key") {
85				Some(Value::String(k)) => k.clone(),
86				Some(Value::Number(n)) => n.to_string(),
87				_ => i.to_string(),
88			};
89			let child_id = format!("{id}.{key}");
90			if !seen.insert(child_id.clone()) {
91				return Err(Error::DuplicateId { path: id, key });
92			}
93			children.push(Self::build(child_id, child, routes)?);
94		}
95		Ok(Self { id, kind, props, children })
96	}
97
98	/// Builds a wire node with the same validation used by plugin views.
99	fn from_json(id: String, node: &Value) -> Result<Self, Error> {
100		let Some(obj) = node.as_object() else {
101			return Err(Error::InvalidView(format!("{id}: a node must be a table, got {node}")));
102		};
103		let Some(kind) = obj.get("k").and_then(Value::as_str) else {
104			return Err(Error::InvalidView(format!("{id}: a node needs a string `k`")));
105		};
106		let props = match obj.get("p") {
107			None | Some(Value::Null) => Map::new(),
108			Some(Value::Object(p)) => p.clone(),
109			Some(other) => {
110				return Err(Error::InvalidView(format!("{id}: `p` must be a table, got {other}")));
111			},
112		};
113		let nodes: &[Value] = match obj.get("c") {
114			None | Some(Value::Null) => &[],
115			Some(Value::Array(c)) => c,
116			Some(Value::Object(c)) if c.is_empty() => &[],
117			Some(other) => {
118				return Err(Error::InvalidView(format!("{id}: `c` must be a list, got {other}")));
119			},
120		};
121		let mut seen = HashSet::with_capacity(nodes.len());
122		let mut children = Vec::with_capacity(nodes.len());
123		for (i, child) in nodes.iter().enumerate() {
124			let key = match child.get("p").and_then(|p| p.get("key")) {
125				Some(Value::String(k)) => k.clone(),
126				Some(Value::Number(n)) => n.to_string(),
127				_ => i.to_string(),
128			};
129			let child_id = format!("{id}.{key}");
130			if !seen.insert(child_id.clone()) {
131				return Err(Error::DuplicateId { path: id, key });
132			}
133			children.push(Self::from_json(child_id, child)?);
134		}
135		Ok(Self { id, kind: kind.to_owned(), props, children })
136	}
137
138	/// Whether the `text` prop is primary text, updated with the `text` op.
139	fn has_text(&self) -> bool {
140		kind::has_text(&self.kind)
141	}
142}
143
144/// What a handler is routed by.
145enum Route {
146	/// `action` events with this action (`act` or `act=value`).
147	Act(String),
148	/// Events of this `ev`.
149	Ev(&'static str),
150}
151
152/// The handlers of a reconciled view, by node id.
153pub(crate) struct Routes<M> {
154	/// Each node's handlers, in the order attached.
155	by_id: HashMap<String, Vec<(Route, Handler<M>)>>,
156}
157
158impl<M> Default for Routes<M> {
159	fn default() -> Self {
160		Self { by_id: HashMap::new() }
161	}
162}
163
164impl<M> Routes<M> {
165	/// The message of the first handler on node `id` that answers `event`.
166	fn at(&self, id: &str, event: &Event, matches: impl Fn(&Route) -> bool) -> Option<M> {
167		self
168			.by_id
169			.get(id)?
170			.iter()
171			.filter(|(r, _)| matches(r))
172			.find_map(|(_, h)| h(event))
173	}
174
175	/// Routes `event` to a handler of the node it names: for a list's
176	/// `select`/`activate`, the item's own handlers first, then the list's.
177	pub(crate) fn route(&self, event: &Event) -> Option<M> {
178		let id = event.id()?;
179		match event {
180			Event::Action(a) => self.at(id, event, |r| {
181				matches!(r, Route::Act(n) if *n == a.act || n.split_once('=')
182					.is_some_and(|(act, value)| act == a.act && a.value.as_deref() == Some(value)))
183			}),
184			Event::Select(s) | Event::Activate(s) => {
185				let name = event.name();
186				let is = |r: &Route| matches!(r, Route::Ev(e) if *e == name);
187				(s.item != id)
188					.then(|| self.at(&s.item, event, is))
189					.flatten()
190					.or_else(|| self.at(id, event, is))
191			},
192			_ => {
193				let name = event.name();
194				self.at(id, event, |r| matches!(r, Route::Ev(e) if *e == name))
195			},
196		}
197	}
198}
199
200/// Merges the actions a node's handlers need into its props and returns
201/// their routes.
202fn attach<M>(
203	id: &str,
204	props: &mut Map<String, Value>,
205	handlers: Vec<(Trigger, Handler<M>)>,
206) -> Vec<(Route, Handler<M>)> {
207	let mut out = Vec::with_capacity(handlers.len());
208	for (trigger, handler) in handlers {
209		let route = match trigger {
210			Trigger::Action(act) => Route::Act(act),
211			Trigger::Event(ev) => Route::Ev(ev),
212			pointer => {
213				let actions = props
214					.entry("actions")
215					.or_insert_with(|| Value::Object(Map::new()));
216				let Value::Object(actions) = actions else {
217					tracing::warn!(id, "`actions` is not an object here; pointer handler ignored");
218					continue;
219				};
220				match pointer {
221					Trigger::Click | Trigger::Dblclick => {
222						let gesture = if pointer == Trigger::Click {
223							"click"
224						} else {
225							"dblclick"
226						};
227						let act = actions
228							.entry(gesture)
229							.or_insert_with(|| Value::String(gesture.into()))
230							.as_str()
231							.unwrap_or(gesture)
232							.to_owned();
233						Route::Act(act)
234					},
235					Trigger::Menu(act) => {
236						let menu = actions
237							.entry("menu")
238							.or_insert_with(|| Value::Array(Vec::new()));
239						if let Value::Array(menu) = menu
240							&& !menu.iter().any(|m| m.as_str() == Some(&act))
241						{
242							menu.push(Value::String(act.clone()));
243						}
244						Route::Act(act)
245					},
246					Trigger::Action(_) | Trigger::Event(_) => unreachable!("matched above"),
247				}
248			},
249		};
250		out.push((route, handler));
251	}
252	out
253}
254
255/// A view with ids assigned: the tree of each region, as last sent.
256#[derive(Clone, Debug, Default, PartialEq)]
257pub struct Doc {
258	/// `main`, `dock`, `layer`.
259	regions: [Option<Tree>; 3],
260}
261
262impl Doc {
263	/// The empty view.
264	pub fn new() -> Self {
265		Self::default()
266	}
267
268	/// Reconciles a view's tree (handlers are dropped).
269	///
270	/// # Errors
271	/// [`Error::DuplicateId`] when two siblings share an id.
272	pub fn from_view<M>(view: View<M>) -> Result<Self, Error> {
273		Self::build(view).map(|(doc, _)| doc)
274	}
275
276	/// Builds a view from its wire form `{main?, dock?, layer?}` of nodes
277	/// `{k, p?, c?}` (`null` is the empty view). Region roots must be nodes,
278	/// not lists; use [`View`] builders for implicit `col` wrapping.
279	///
280	/// Missing or null `p` and `c` mean no props or children. An empty
281	/// object `c` also means no children (an empty Luau table). Other `p`
282	/// values must be objects and other `c` values must be lists. Errors
283	/// include the node path; duplicate-key errors also name the key.
284	///
285	/// # Errors
286	/// [`Error::InvalidView`] for a malformed view, [`Error::DuplicateId`]
287	/// when two siblings share an id.
288	pub fn from_json(value: &Value) -> Result<Self, Error> {
289		let mut regions = [None, None, None];
290		match value {
291			Value::Null => {},
292			Value::Object(obj) => {
293				if let Some(extra) = obj.keys().find(|k| !REGIONS.contains(&k.as_str())) {
294					return Err(Error::InvalidView(format!(
295						"view: unknown region {extra:?} (main, dock or layer)"
296					)));
297				}
298				for (slot, name) in regions.iter_mut().zip(REGIONS) {
299					if let Some(node) = obj.get(name).filter(|n| !n.is_null()) {
300						*slot = Some(Tree::from_json(name.to_owned(), node)?);
301					}
302				}
303			},
304			other => {
305				return Err(Error::InvalidView(format!(
306					"view: expected {{main?, dock?, layer?}}, got {other}"
307				)));
308			},
309		}
310		Ok(Self { regions })
311	}
312
313	/// Reconciles a view, collecting the routes of its handlers.
314	pub(crate) fn build<M>(view: View<M>) -> Result<(Self, Routes<M>), Error> {
315		let mut routes = Routes::default();
316		let mut regions = [None, None, None];
317		let View { main, dock, layer } = view;
318		for ((slot, name), node) in regions.iter_mut().zip(REGIONS).zip([main, dock, layer]) {
319			if let Some(node) = node {
320				*slot = Some(Tree::build(name.to_owned(), node, &mut routes)?);
321			}
322		}
323		Ok((Self { regions }, routes))
324	}
325
326	/// Drops transient regions removed when a surface closes.
327	pub(crate) fn retain_main(&mut self) {
328		self.regions[1] = None;
329		self.regions[2] = None;
330	}
331
332	/// The region trees in order `main`, `dock`, `layer`.
333	pub const fn regions(&self) -> &[Option<Tree>; 3] {
334		&self.regions
335	}
336
337	/// The ops turning this view into `next` on surface `surface`.
338	pub fn ops(&self, next: &Self, surface: &str) -> Vec<Op> {
339		let mut ops = Vec::new();
340		for (old, new) in self.regions.iter().zip(&next.regions) {
341			match (old, new) {
342				(None, None) => {},
343				(Some(old), None) => ops.push(Op::Del { id: old.id.clone() }),
344				(None, Some(new)) => ops.push(add(new, surface, None)),
345				(Some(old), Some(new)) if old.kind != new.kind => {
346					ops.push(Op::Del { id: old.id.clone() });
347					ops.push(add(new, surface, None));
348				},
349				(Some(old), Some(new)) => diff(old, new, &mut ops),
350			}
351		}
352		ops
353	}
354}
355
356/// The `add` op of `tree` under `parent`, before `before`.
357fn add(tree: &Tree, parent: &str, before: Option<&str>) -> Op {
358	Op::Add { parent: parent.to_owned(), before: before.map(str::to_owned), node: tree.wire() }
359}
360
361/// Ops turning `old` into `new`, which share id and kind.
362fn diff(old: &Tree, new: &Tree, ops: &mut Vec<Op>) {
363	let text = new.has_text();
364	let mut set = Map::new();
365	for (key, value) in &new.props {
366		if text && key == "text" {
367			continue;
368		}
369		if old.props.get(key) != Some(value) {
370			set.insert(key.clone(), value.clone());
371		}
372	}
373	for key in old.props.keys() {
374		let primary = text && key == "text";
375		if !primary && !new.props.contains_key(key) {
376			set.insert(key.clone(), Value::Null);
377		}
378	}
379	if !set.is_empty() {
380		ops.push(Op::Set { id: new.id.clone(), props: set });
381	}
382	if text {
383		let before = old.props.get("text");
384		let after = new.props.get("text");
385		if before != after {
386			match (before.and_then(Value::as_str), after.and_then(Value::as_str)) {
387				(Some(a), Some(b)) if !a.is_empty() && b.starts_with(a) => ops.push(Op::Text {
388					id:   new.id.clone(),
389					mode: TextMode::Append,
390					text: b[a.len()..].to_owned(),
391				}),
392				(_, Some(b)) => ops.push(Op::Text {
393					id:   new.id.clone(),
394					mode: TextMode::Replace,
395					text: b.to_owned(),
396				}),
397				(_, None) => {
398					let mut props = Map::new();
399					props.insert("text".into(), Value::Null);
400					ops.push(Op::Set { id: new.id.clone(), props });
401				},
402			}
403		}
404	}
405	children(old, new, ops);
406}
407
408/// Ops turning `old`'s children into `new`'s: deletions first, then from
409/// the last child back each new, re-kinded or out-of-order child is placed
410/// before its (already placed) next sibling, and kept children diff.
411fn children(old: &Tree, new: &Tree, ops: &mut Vec<Op>) {
412	let before: HashMap<&str, (usize, &Tree)> = old
413		.children
414		.iter()
415		.enumerate()
416		.map(|(i, c)| (c.id.as_str(), (i, c)))
417		.collect();
418	let kept: HashSet<&str> = new.children.iter().map(|c| c.id.as_str()).collect();
419	for child in &old.children {
420		if !kept.contains(child.id.as_str()) {
421			ops.push(Op::Del { id: child.id.clone() });
422		}
423	}
424	// Children that keep id and kind; those on the longest run of rising old
425	// positions stay put, the rest move.
426	let same: Vec<(usize, usize)> = new
427		.children
428		.iter()
429		.enumerate()
430		.filter_map(|(at, c)| {
431			before
432				.get(c.id.as_str())
433				.filter(|(_, o)| o.kind == c.kind)
434				.map(|(i, _)| (at, *i))
435		})
436		.collect();
437	let stay: HashSet<usize> = rising(&same.iter().map(|(_, i)| *i).collect::<Vec<_>>())
438		.into_iter()
439		.map(|k| same[k].0)
440		.collect();
441	let mut next: Option<&str> = None;
442	for (at, child) in new.children.iter().enumerate().rev() {
443		match before.get(child.id.as_str()) {
444			None => ops.push(add(child, &new.id, next)),
445			Some((_, o)) if o.kind != child.kind => {
446				ops.push(Op::Del { id: child.id.clone() });
447				ops.push(add(child, &new.id, next));
448			},
449			Some((_, o)) => {
450				if !stay.contains(&at) {
451					ops.push(Op::Move {
452						id:     child.id.clone(),
453						parent: new.id.clone(),
454						before: next.map(str::to_owned),
455					});
456				}
457				diff(o, child, ops);
458			},
459		}
460		next = Some(child.id.as_str());
461	}
462}
463
464/// Indices into `seq` of one longest strictly rising subsequence.
465fn rising(seq: &[usize]) -> Vec<usize> {
466	// tails[k]: index of the smallest tail of a rising run of length k + 1.
467	let mut tails: Vec<usize> = Vec::new();
468	let mut prev: Vec<Option<usize>> = vec![None; seq.len()];
469	for (i, &v) in seq.iter().enumerate() {
470		let at = tails.partition_point(|&t| seq[t] < v);
471		prev[i] = at.checked_sub(1).map(|p| tails[p]);
472		if at == tails.len() {
473			tails.push(i);
474		} else {
475			tails[at] = i;
476		}
477	}
478	let mut out = Vec::with_capacity(tails.len());
479	let mut cur = tails.last().copied();
480	while let Some(i) = cur {
481		out.push(i);
482		cur = prev[i];
483	}
484	out.reverse();
485	out
486}
487
488#[cfg(test)]
489mod tests {
490	use serde_json::json;
491
492	use super::*;
493	use crate::ui;
494
495	#[test]
496	fn json_empty_luau_tables_are_empty_children() {
497		let bare = Doc::from_json(&json!({"main": {"k": "col"}})).unwrap();
498		for children in [Value::Null, json!({}), json!([])] {
499			let doc = Doc::from_json(&json!({
500				"main": {"k": "col", "p": null, "c": children},
501				"dock": null, "layer": null,
502			}))
503			.unwrap();
504			assert_eq!(doc, bare);
505			assert!(doc.ops(&bare, "p").is_empty());
506		}
507		let nested = Doc::from_json(&json!({
508			"main": {"k": "col", "c": [{"k": "col", "p": {}, "c": {}}]},
509		}))
510		.unwrap();
511		assert!(
512			nested.regions()[0].as_ref().unwrap().children()[0]
513				.children()
514				.is_empty()
515		);
516		assert_eq!(Doc::from_json(&Value::Null).unwrap(), Doc::new());
517	}
518
519	#[test]
520	fn json_rejects_unknown_regions_even_when_null() {
521		for node in [Value::Null, json!({"k": "col"})] {
522			let error = Doc::from_json(&json!({"side": node})).unwrap_err();
523			assert_eq!(error.to_string(), "view: unknown region \"side\" (main, dock or layer)");
524		}
525		for value in [json!(1), json!([]), json!("main")] {
526			assert!(matches!(Doc::from_json(&value), Err(Error::InvalidView(_))));
527		}
528	}
529
530	#[test]
531	fn json_rejects_malformed_nodes_with_paths() {
532		for (node, message) in [
533			(json!(1), "a node must be a table, got 1"),
534			(json!([]), "a node must be a table, got []"),
535			(json!({}), "a node needs a string `k`"),
536			(json!({"k": false}), "a node needs a string `k`"),
537			(json!({"k": "col", "p": []}), "`p` must be a table, got []"),
538			(json!({"k": "col", "p": 1}), "`p` must be a table, got 1"),
539			(json!({"k": "col", "c": {"bad": 1}}), "`c` must be a list, got {\"bad\":1}"),
540			(json!({"k": "col", "c": false}), "`c` must be a list, got false"),
541			(json!({"k": "col", "c": "bad"}), "`c` must be a list, got \"bad\""),
542		] {
543			let error = Doc::from_json(&json!({"main": node})).unwrap_err();
544			assert_eq!(error.to_string(), format!("main: {message}"));
545			let error = Doc::from_json(&json!({
546				"main": {"k": "col", "c": [{"k": "col", "p": {"key": "nested"}, "c": [node]}]},
547			}))
548			.unwrap_err();
549			assert_eq!(error.to_string(), format!("main.nested.0: {message}"));
550		}
551	}
552
553	#[test]
554	fn duplicate_child_errors_name_parent_and_key() {
555		for (keys, path, key) in [
556			([json!("a"), json!("a")], "main", "a"),
557			([json!(7), json!("7")], "main", "7"),
558			([json!("a.b"), json!("a.b")], "main", "a.b"),
559		] {
560			let children = keys.map(|key| json!({"k": "text", "p": {"key": key}}));
561			let node = json!({"k": "col", "c": children});
562			let error = Doc::from_json(&json!({"main": node})).unwrap_err();
563			assert_eq!(error.to_string(), format!("{path}: duplicate child key {key:?}"));
564			assert!(matches!(error, Error::DuplicateId { path: p, key: k } if p == path && k == key));
565			let error = Doc::from_json(&json!({
566				"main": {"k": "col", "c": [node]},
567			}))
568			.unwrap_err();
569			assert_eq!(error.to_string(), format!("main.0: duplicate child key {key:?}"));
570		}
571		let view = View::<()>::new().main([ui::text("a").key("x"), ui::text("b").key("x")]);
572		assert_eq!(Doc::from_view(view).unwrap_err().to_string(), "main: duplicate child key \"x\"");
573	}
574
575	#[test]
576	fn integer_keys_derive_decimal_ids() {
577		let doc = Doc::from_json(&json!({
578			"main": {"k": "col", "c": [
579				{"k": "text", "p": {"key": -42}},
580				{"k": "text", "p": {"key": u64::MAX}},
581			]},
582		}))
583		.unwrap();
584		let children = doc.regions()[0].as_ref().unwrap().children();
585		assert_eq!(children[0].id(), "main.-42");
586		assert_eq!(children[1].id(), "main.18446744073709551615");
587	}
588
589	#[test]
590	fn pointer_actions_match_both_name_and_value() {
591		let (doc, routes) = Doc::build(View::from(
592			ui::text("open")
593				.prop("actions", json!({"click": "open=one=two", "dblclick": "open=other"}))
594				.on_click(1)
595				.on_dblclick(2)
596				.on_action("fallback", 3),
597		))
598		.unwrap();
599		assert_eq!(
600			doc.regions()[0].as_ref().unwrap().children()[0].props()["actions"],
601			json!({"click": "open=one=two", "dblclick": "open=other"})
602		);
603		for (act, value, expected) in [
604			("open", Some("one=two"), Some(1)),
605			("open", Some("other"), Some(2)),
606			("open", Some("wrong"), None),
607			("open", None, None),
608			("wrong", Some("one=two"), None),
609			("fallback", Some("anything"), Some(3)),
610		] {
611			let mut raw = json!({"ev": "action", "sf": "s1", "id": "main.0", "act": act});
612			if let Some(value) = value {
613				raw["value"] = json!(value);
614			}
615			let event = Event::decode(raw.as_object().unwrap().clone());
616			assert_eq!(routes.route(&event), expected);
617		}
618	}
619
620	#[test]
621	fn retaining_main_drops_only_transient_regions() {
622		let mut doc = Doc::from_view(
623			View::<()>::new()
624				.main([ui::text("kept")])
625				.dock([ui::text("draft")])
626				.layer([ui::text("dialog")]),
627		)
628		.unwrap();
629		let main = doc.regions()[0].clone();
630		doc.retain_main();
631		assert_eq!(doc.regions(), &[main, None, None]);
632		let next = Doc::from_view(View::<()>::new().main([ui::text("kept")])).unwrap();
633		assert!(doc.ops(&next, "s1").is_empty());
634	}
635}