//! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was //! marked. The document keeps a snapshot of the layer from just before its first change //! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer //! (matched by id): layers added and removed, and for the others their properties formatted the //! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`). use std::collections::BTreeSet; use figcraft_doc::*; use serde::Serialize; use crate::devinspect::{Prop, Section, properties}; /// Most changed layers listed (the rest is counted as truncated). pub const MAX_CHANGES: usize = 2_000; /// Deepest nesting walked. const MAX_DEPTH: usize = 256; /// Longest text content shown in a change (chars). const MAX_TEXT: usize = 60; /// One property that changed, as Inspect formats it. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct PropChange { /// The group it belongs to ("Layer", "Layout", "Fills"…). pub section: &'static str, /// What changed, lower case ("width", "fill", "font size"…). pub property: String, pub before: String, pub after: String, /// The whole change as one line: `width 120 → 136`. pub text: String, } impl PropChange { pub fn new(section: &'static str, property: String, before: String, after: String) -> Self { let text = format!("{property} {before} → {after}"); Self { section, property, before, after, text } } } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum ChangeKind { Added, Removed, Changed, } impl ChangeKind { pub fn label(self) -> &'static str { match self { ChangeKind::Added => "Added", ChangeKind::Removed => "Removed", ChangeKind::Changed => "Changed", } } } /// A layer that was added, removed or changed. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct LayerChange { pub id: NodeId, pub name: String, #[serde(rename = "type")] pub type_name: &'static str, pub change: ChangeKind, /// How deep under the compared layer it is (0: the layer itself). pub depth: usize, /// Added or removed: how many layers that is (the layer and everything in it). pub layers: usize, /// Changed: what changed. #[serde(skip_serializing_if = "Vec::is_empty")] pub properties: Vec, } /// The changes to a ready-for-dev layer since it was marked. #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct Comparison { pub id: NodeId, pub name: String, /// It changed since it was marked (shown as "Changed"). pub changed: bool, /// What it looked like before is known (files from before snapshots were kept, and layers too /// large to snapshot, have none). pub snapshot: bool, pub changes: Vec, pub added: usize, pub removed: usize, pub modified: usize, /// More than [`MAX_CHANGES`] layers changed: the list stops there. pub truncated: bool, } /// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed). pub fn compare(doc: &Document, id: NodeId) -> Comparison { let n = doc.node(id); let mut out = Comparison { id, name: n.map(|n| n.name.clone()).unwrap_or_default(), changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed), snapshot: false, changes: Vec::new(), added: 0, removed: 0, modified: 0, truncated: false, }; if !out.changed { return out; } let Some(before) = doc.with_dev_snapshot(id) else { return out }; out.snapshot = true; let mut w = Walk { before: &before, now: doc, now_ids: bounded_set(doc, id), before_ids: bounded_set(&before, id), out: &mut out }; w.layer(id, 0); out } /// The before and after documents of layer `id` (the snapshot's copy and the document), for /// rendering the two side by side. pub fn before_doc(doc: &Document, id: NodeId) -> Option { doc.node(id).filter(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed)?; doc.with_dev_snapshot(id) } /// Every layer that changed between two versions of a whole document, page by page (pages added /// or removed as a whole), for reviewing a branch. pub fn compare_docs(before: &Document, now: &Document) -> Comparison { let mut out = Comparison { id: NodeId(0), name: now.name.clone(), changed: false, snapshot: true, changes: Vec::new(), added: 0, removed: 0, modified: 0, truncated: false, }; let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out }; for p in &now.pages { if before.contains(*p) { w.layer(*p, 0); } else { w.whole(now, *p, 0, ChangeKind::Added); } } for p in before.pages.iter().filter(|p| !now.contains(**p)) { w.whole(before, *p, 0, ChangeKind::Removed); } out.changed = !out.changes.is_empty(); out } fn bounded_set(doc: &Document, id: NodeId) -> BTreeSet { doc.subtree(id).into_iter().take(MAX_SNAPSHOT_NODES * 2).collect() } struct Walk<'a> { before: &'a Document, now: &'a Document, now_ids: BTreeSet, before_ids: BTreeSet, out: &'a mut Comparison, } impl Walk<'_> { fn push(&mut self, c: LayerChange) { if self.out.changes.len() >= MAX_CHANGES { self.out.truncated = true; return; } match c.change { ChangeKind::Added => self.out.added += 1, ChangeKind::Removed => self.out.removed += 1, ChangeKind::Changed => self.out.modified += 1, } self.out.changes.push(c); } fn whole(&mut self, doc: &Document, id: NodeId, depth: usize, change: ChangeKind) { let Some(n) = doc.node(id) else { return }; let layers = doc.subtree(id).len().min(MAX_SNAPSHOT_NODES * 2); self.push(LayerChange { id, name: n.name.clone(), type_name: n.kind.type_name(), change, depth, layers, properties: Vec::new() }); } /// Layer `id` (in both versions) and what's under it: its own changes, then its children as they /// are now (added ones as a whole), then the children it lost (removed as a whole). fn layer(&mut self, id: NodeId, depth: usize) { if depth > MAX_DEPTH || self.out.truncated { return; } let Some(now) = self.now.node(id) else { return }; // A layer shared by both versions under a shared parent didn't change (nor its place). let shared = |n: NodeId| self.before.node_arc(n).zip(self.now.node_arc(n)).is_some_and(|(x, y)| std::sync::Arc::ptr_eq(x, y)); let props = if shared(id) && now.parent.is_none_or(shared) { Vec::new() } else { layer_changes(self.before, self.now, id) }; if !props.is_empty() { self.push(LayerChange { id, name: now.name.clone(), type_name: now.kind.type_name(), change: ChangeKind::Changed, depth, layers: 1, properties: props, }); } for c in now.children.iter().rev() { if self.before_ids.contains(c) { self.layer(*c, depth + 1); } else { self.whole(self.now, *c, depth + 1, ChangeKind::Added); } } let lost: Vec = self.before.node(id).map(|b| b.children.iter().rev().copied().filter(|c| !self.now_ids.contains(c)).collect()).unwrap_or_default(); for c in lost { self.whole(self.before, c, depth + 1, ChangeKind::Removed); } } } /// What changed on layer `id` between `before` and `now` (both must hold it). pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec { let (Some(b), Some(a)) = (before.node(id), now.node(id)) else { return Vec::new() }; let mut out = Vec::new(); let mut put = |section: &'static str, property: &str, before: String, after: String| { if before != after { out.push(PropChange::new(section, property.to_string(), before, after)); } }; let parent_name = |d: &Document, n: &Node| n.parent.and_then(|p| d.node(p)).map(|p| p.name.clone()).unwrap_or_default(); if b.parent != a.parent { put("Layer", "parent", parent_name(before, b), parent_name(now, a)); } else if let Some((ib, ia)) = order(before, now, id) { put("Layer", "order", ib.to_string(), ia.to_string()); } // Unchanged layers (shared with the snapshot, or equal) only move with their parent. let same = before.node_arc(id).zip(now.node_arc(id)).is_some_and(|(x, y)| std::sync::Arc::ptr_eq(x, y)) || b.same_design(a); if same { return out; } put("Layer", "name", b.name.clone(), a.name.clone()); let shown = |n: &Node| if n.visible { "visible" } else { "hidden" }.to_string(); put("Layer", "visibility", shown(b), shown(a)); if b.blend != a.blend { put("Layer", "blend", b.blend.label().to_string(), a.blend.label().to_string()); } if a.kind.is_frame_like() && b.kind.is_frame_like() { let yes = |v: bool| if v { "on" } else { "off" }.to_string(); put("Layer", "clip content", yes(b.clip), yes(a.clip)); } if let (NodeKind::Text(tb), NodeKind::Text(ta)) = (&b.kind, &a.kind) { put("Layer", "text", quoted(&tb.characters), quoted(&ta.characters)); } if let (NodeKind::Instance { component: cb, props: pb }, NodeKind::Instance { component: ca, props: pa }) = (&b.kind, &a.kind) { let main = |d: &Document, c: &NodeId| d.node(*c).map(|n| n.name.clone()).unwrap_or_else(|| c.to_string()); put("Layer", "component", main(before, cb), main(now, ca)); let keys: BTreeSet<&String> = pb.keys().chain(pa.keys()).collect(); for k in keys { let v = |m: &std::collections::BTreeMap| m.get(k).map(value_text).unwrap_or_else(|| "default".into()); put("Layer", k.split('#').next().unwrap_or(k), v(pb), v(pa)); } } if b.kind.type_name() != a.kind.type_name() { put("Layer", "type", b.kind.type_name().to_string(), a.kind.type_name().to_string()); } for c in section_changes(&properties(before, id), &properties(now, id)) { out.push(c); } out } /// Layer `id`'s place (1 = top of the layers panel) among the siblings it had in both versions, /// when that changed. fn order(before: &Document, now: &Document, id: NodeId) -> Option<(usize, usize)> { let parent = now.node(id)?.parent?; let (pb, pa) = (before.node(parent)?, now.node(parent)?); let common_b: Vec = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect(); let common_a: Vec = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect(); let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?); (ib != ia).then_some((ib + 1, ia + 1)) } fn quoted(s: &str) -> String { let mut t: String = s.chars().take(MAX_TEXT).collect(); if s.chars().count() > MAX_TEXT { t.push('…'); } format!("“{t}”") } fn value_text(v: &serde_json::Value) -> String { match v { serde_json::Value::String(s) => s.clone(), other => other.to_string(), } } /// The sections whose rows form a list (one row per paint or effect). const LISTS: [(&str, &str); 3] = [("Fills", "fill"), ("Strokes", "stroke"), ("Effects", "effect")]; /// The changes between two layers' inspected properties. fn section_changes(before: &[Section], after: &[Section]) -> Vec { let mut titles: Vec<&'static str> = after.iter().map(|s| s.title).collect(); for s in before { if !titles.contains(&s.title) { titles.push(s.title); } } let rows = |list: &[Section], title: &str| -> Vec { list.iter().find(|s| s.title == title).map(|s| s.rows.clone()).unwrap_or_default() }; let mut out = Vec::new(); for title in titles { let (b, a) = (rows(before, title), rows(after, title)); match LISTS.iter().find(|(t, _)| *t == title) { Some((_, base)) => { // A stroke's weight is a row of its own, not a paint. let (bw, b): (Vec, Vec) = b.into_iter().partition(|r| r.label == "Weight"); let (aw, a): (Vec, Vec) = a.into_iter().partition(|r| r.label == "Weight"); list_changes(title, base, &b, &a, &mut out); keyed_changes(title, &bw, &aw, &mut out); } None => keyed_changes(title, &b, &a, &mut out), } } out } /// Rows paired by position: `fill #FF0000 → #E03030`, `fill 2 none → #FFFFFF`. fn list_changes(section: &'static str, base: &str, before: &[Prop], after: &[Prop], out: &mut Vec) { let n = before.len().max(after.len()); for i in 0..n { let (b, a) = (before.get(i), after.get(i)); // Same kind of effect: name it ("drop shadow"); paints are named by their list. let kind = match (b, a) { (Some(x), Some(y)) if base == "effect" && x.label == y.label => x.label.to_lowercase(), (Some(x), None) | (None, Some(x)) if base == "effect" => x.label.to_lowercase(), _ => base.to_string(), }; let property = if n > 1 { format!("{kind} {}", i + 1) } else { kind }; let text = |p: Option<&Prop>, other: Option<&Prop>| match p { Some(p) => { let v = if p.label == "Solid" || base == "effect" && other.is_some_and(|o| o.label == p.label) { p.value.clone() } else { format!("{} {}", p.label, p.value) }; with_token(v, p, other) } None => "none".to_string(), }; let (tb, ta) = (text(b, a), text(a, b)); if tb != ta { out.push(PropChange::new(section, property, tb, ta)); } } } /// Rows paired by label: `width 120 → 136`. fn keyed_changes(section: &'static str, before: &[Prop], after: &[Prop], out: &mut Vec) { let mut labels: Vec<&str> = after.iter().map(|r| r.label.as_str()).collect(); for r in before { if !labels.contains(&r.label.as_str()) { labels.push(&r.label); } } for label in labels { // Text content is compared as the layer's text. if label == "Content" { continue; } let (b, a) = (before.iter().find(|r| r.label == label), after.iter().find(|r| r.label == label)); let text = |p: Option<&Prop>, other: Option<&Prop>| match p { Some(p) => with_token(p.value.clone(), p, other), None => missing(section, label).to_string(), }; let (tb, ta) = (text(b, a), text(a, b)); if tb != ta { out.push(PropChange::new(section, property_name(section, label), tb, ta)); } } } /// A row's value, with the style or variable it comes from when that differs from the other side's. fn with_token(value: String, p: &Prop, other: Option<&Prop>) -> String { match &p.token { Some(t) if other.is_none_or(|o| o.token.as_ref() != Some(t)) => format!("{value} ({t})"), _ => value, } } /// What a row Inspect leaves out means. fn missing(section: &str, label: &str) -> &'static str { match (section, label) { (_, "Rotation") => "0°", (_, "Opacity") => "100%", (_, "Radius") | (_, "Paragraph spacing") => "0", _ => "none", } } /// A row's property name in a change: Inspect's label, lower case, made unambiguous. fn property_name(section: &str, label: &str) -> String { let name = match (section, label) { ("Layout", "Radius") => "corner radius", ("Auto layout", "Direction") => "auto layout", ("Auto layout", "Align") => "alignment", ("Typography", "Style") => "font style", ("Typography", "Size") => "font size", ("Typography", "Align") => "text align", ("Strokes", "Weight") => "stroke weight", _ => return label.to_lowercase(), }; name.to_string() }