//! The FigCraft engine. //! //! Every user-visible action is a command with a stable id (`node.create`, `align.left`, //! `layout.add`, `component.create`…) taking JSON params. The egui UI, the CLI, the control channel //! and the MCP server all go through [`Session::execute`], so everything is scriptable and every //! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document //! edits and are journaled the same way. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod catalog; pub mod cmd; pub mod codegen; pub mod components; pub mod devinspect; pub mod drop; pub mod guard; pub mod inspect; pub mod library; pub mod measure; pub mod params; pub mod shapebuilder; pub mod snap; pub mod synthetic; pub mod textedit; pub mod tools; pub mod vectoredit; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_doc as doc; pub use tools::{PointerEvent, PointerKind, ToolId}; use std::collections::BTreeSet; use std::sync::Arc; use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; #[derive(Debug, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] UnknownCommand(String), #[error("command `{0}` is not available right now: {1}")] Disabled(String, String), #[error("invalid parameters for `{cmd}`: {msg}")] BadParams { cmd: String, msg: String }, #[error("no open document")] NoDocument, #[error("no such node {0}")] NoNode(NodeId), #[error("{0}")] Other(String), /// A bug: the command panicked. The document was rolled back. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, } impl From for EngineError { fn from(e: figcraft_doc::DocError) -> Self { match e { figcraft_doc::DocError::NoNode(id) => EngineError::NoNode(id), other => EngineError::Other(other.to_string()), } } } pub type Result = std::result::Result; pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { pub label: String, pub doc: Arc, pub selection: Vec, pub page: NodeId, } #[derive(Clone, Debug, Default)] pub struct History { pub undo: Vec, pub redo: Vec, pub limit: usize, } /// The canvas view of a document: what's at the centre of the viewport and the zoom. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ViewState { /// Page coordinate at the viewport centre. pub cx: f64, pub cy: f64, /// Zoom (1 = 100%). pub zoom: f64, /// Viewport size in logical pixels (set by the UI; used by zoom-to-fit). pub vw: f64, pub vh: f64, pub pixel_grid: bool, pub layout_grids: bool, pub rulers: bool, pub outlines: bool, pub pixel_preview: bool, pub show_slices: bool, pub comments: bool, pub frame_outlines: bool, pub snap_geometry: bool, pub snap_objects: bool, pub snap_pixel: bool, pub ui_hidden: bool, } impl Default for ViewState { fn default() -> Self { Self { cx: 0.0, cy: 0.0, zoom: 1.0, vw: 1200.0, vh: 800.0, pixel_grid: true, layout_grids: true, rulers: false, outlines: false, pixel_preview: false, show_slices: false, comments: true, frame_outlines: false, snap_geometry: true, snap_objects: true, snap_pixel: true, ui_hidden: false, } } } impl ViewState { pub const MIN_ZOOM: f64 = 0.02; pub const MAX_ZOOM: f64 = 256.0; /// Page coordinate at the viewport's top-left. pub fn origin(&self) -> (f64, f64) { let z = self.zoom.max(1e-9); (self.cx - self.vw / 2.0 / z, self.cy - self.vh / 2.0 / z) } pub fn set_zoom_at(&mut self, zoom: f64, anchor: (f64, f64)) { let z = if zoom.is_finite() { zoom.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM) } else { self.zoom }; let old = self.zoom.max(1e-9); // Keep the page point under `anchor` fixed. self.cx = anchor.0 + (self.cx - anchor.0) * old / z; self.cy = anchor.1 + (self.cy - anchor.1) * old / z; self.zoom = z; } /// Fit a page rectangle into the viewport with margin. pub fn fit(&mut self, r: figcraft_geom::Rect, max_zoom: f64) { if !(r.width() > 0.0 || r.height() > 0.0) || !r.x0.is_finite() { self.cx = r.center().x; self.cy = r.center().y; return; } let margin = 64.0; let zx = (self.vw - margin * 2.0).max(50.0) / r.width().max(1e-6); let zy = (self.vh - margin * 2.0).max(50.0) / r.height().max(1e-6); self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom); self.cx = r.center().x; self.cy = r.center().y; } pub fn page_to_screen(&self, x: f64, y: f64) -> (f64, f64) { let (ox, oy) = self.origin(); ((x - ox) * self.zoom, (y - oy) * self.zoom) } pub fn screen_to_page(&self, x: f64, y: f64) -> (f64, f64) { let (ox, oy) = self.origin(); let z = self.zoom.max(1e-9); (ox + x / z, oy + y / z) } } /// An in-progress gesture: the document before it started, so previews re-apply from scratch. #[derive(Clone, Debug)] pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Vec, } /// The text being edited on the canvas. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct TextEdit { pub id: NodeId, /// Caret position and selection anchor (char indices). pub caret: usize, pub anchor: usize, /// The node was created by this edit (deleted if left empty). pub created: bool, /// An IME composition in progress, shown inline at the caret; the document gets it on commit. #[serde(default, skip_serializing_if = "Option::is_none")] pub preedit: Option, } /// IME composition text (not yet in the document) and the IME's cursor inside it (chars). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Preedit { pub text: String, pub cursor: usize, } /// Per-document editing state. #[derive(Clone, Debug)] pub struct DocState { pub doc: Arc, pub selection: Vec, pub page: NodeId, pub history: History, saved_doc: Arc, pub path: Option, pub revision: u64, pub view: ViewState, pub interaction: Option, pub text_edit: Option, /// Hovered node (canvas hover outline). pub hover: Option, /// Vector edit mode: the vector being edited, its selected points and the edit tool. pub vector_edit: Option, pub uid: u64, /// The file's version history, oldest first (saved in the `.figcraft` file; not undoable). pub versions: Vec, /// The version history changed since the last save. versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, /// The document as of the last version saved (or as opened): autosaves only follow changes. checkpoint: Arc, } /// A version shown on the canvas in place of the document: read-only until it's restored or the /// preview ends, which puts the document back as it was. #[derive(Clone, Debug)] pub struct VersionPreview { pub id: String, pub name: String, /// When the version was saved (Unix seconds). pub time: u64, doc: Arc, selection: Vec, page: NodeId, } /// Why edits are refused while a version is shown. pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit"; /// Commands that first end a version preview: they act on the document itself. const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); impl DocState { pub fn new(mut doc: Document, path: Option) -> Self { if doc.pages.is_empty() { doc.repair(); } let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { checkpoint: doc.clone(), saved_doc: doc.clone(), doc, selection: Vec::new(), page, history: History { limit: 1000, ..Default::default() }, path, revision: 1, view: ViewState::default(), interaction: None, text_edit: None, hover: None, vector_edit: None, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), versions: Vec::new(), versions_changed: false, preview: None, }; st.zoom_to_fit(); st } /// With the version history read from its file. pub fn with_versions(mut self, versions: Vec) -> Self { self.versions = versions; self } /// The version history, for editing (the file then has unsaved changes). pub fn versions_mut(&mut self) -> &mut Vec { self.versions_changed = true; &mut self.versions } /// The document changed since the last version was saved (or since it was opened). pub fn changed_since_checkpoint(&self) -> bool { !Arc::ptr_eq(self.preview.as_ref().map_or(&self.doc, |p| &p.doc), &self.checkpoint) } /// Note that a version of the current document was just saved. pub(crate) fn mark_checkpoint(&mut self) { self.checkpoint = self.doc.clone(); } pub fn is_dirty(&self) -> bool { let doc = self.preview.as_ref().map_or(&self.doc, |p| &p.doc); !Arc::ptr_eq(doc, &self.saved_doc) || self.versions_changed } /// Show `version` in place of the document (or switch to it from another preview). pub fn start_preview(&mut self, id: &str, name: &str, time: u64, doc: Arc) { self.end_preview(); self.preview = Some(VersionPreview { id: id.to_string(), name: name.to_string(), time, doc: std::mem::replace(&mut self.doc, doc), selection: std::mem::take(&mut self.selection), page: self.page, }); self.page = Some(self.page) .filter(|pg| self.doc.pages.contains(pg)) .or_else(|| self.doc.current_page.filter(|pg| self.doc.pages.contains(pg))) .or_else(|| self.doc.first_page()) .unwrap_or(self.page); self.text_edit = None; self.vector_edit = None; self.interaction = None; self.revision += 1; } /// End a version preview, putting the document, selection and page back. False when none. pub fn end_preview(&mut self) -> bool { let Some(p) = self.preview.take() else { return false }; self.doc = p.doc; self.selection = p.selection; self.page = p.page; self.text_edit = None; self.vector_edit = None; self.interaction = None; self.clean_selection(); self.revision += 1; true } pub fn mark_saved(&mut self) { self.saved_doc = self.doc.clone(); self.versions_changed = false; } pub fn doc_mut(&mut self) -> &mut Document { Arc::make_mut(&mut self.doc) } /// Fit the current page's content (or centre on the origin when empty). pub fn zoom_to_fit(&mut self) { match self.doc.page_content_bounds(self.page) { Some(b) => self.view.fit(b, 1.0), None => { self.view.zoom = 1.0; self.view.cx = self.view.vw / 2.0 - 100.0; self.view.cy = self.view.vh / 2.0 - 100.0; } } } /// Drop selected ids that no longer exist or aren't on the current page. pub fn clean_selection(&mut self) { let doc = self.doc.clone(); let page = self.page; self.selection.retain(|id| doc.contains(*id) && doc.page_of(*id) == Some(page) && *id != page); let mut seen = BTreeSet::new(); self.selection.retain(|id| seen.insert(*id)); if let Some(te) = &self.text_edit && !doc.contains(te.id) { self.text_edit = None; } // Vector edit mode ends when its layer is gone (or no longer a vector, e.g. undone). match self.vector_edit.as_mut() { Some(ve) if doc.page_of(ve.id) == Some(page) => match vectoredit::network(&doc, ve.id) { Some(net) => ve.clamp(net), None => self.vector_edit = None, }, Some(_) => self.vector_edit = None, None => {} } } } /// App-wide preferences that commands read (nudge amounts, behaviours). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Prefs { pub nudge_small: f64, pub nudge_big: f64, pub keep_tool: bool, pub rename_duplicates: bool, pub highlight_on_hover: bool, pub number_keys_opacity: bool, pub show_dimensions: bool, pub theme: String, } impl Default for Prefs { fn default() -> Self { Self { nudge_small: 1.0, nudge_big: 10.0, keep_tool: false, rename_duplicates: false, highlight_on_hover: true, number_keys_opacity: true, show_dimensions: true, theme: "light".into(), } } } /// A request from a command to the UI (open a dialog or panel, show a message). Programmatic /// callers ignore them. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct UiRequest { pub kind: String, pub data: Value, } /// Clipboard contents (internal; the UI mirrors to the system clipboard). #[derive(Clone, Debug, Default)] pub struct Clipboard { /// Copied subtrees: (root, nodes) in z-order. pub nodes: Vec<(NodeId, Vec)>, pub images: std::collections::BTreeMap, /// Copied properties (Copy properties / Paste properties). pub props: Option, /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, } /// The editing session: open documents, the current tool, clipboard and prefs. pub struct Session { pub docs: Vec, pub active: usize, pub clipboard: Clipboard, pub prefs: Prefs, pub tool: tools::ToolState, pub ui_requests: Vec, /// Command log (id + params) for actions/automation. pub journal: Vec<(String, Value)>, /// Recently used colours. pub recent_colors: Vec, /// The last command executed through [`Session::execute`] (for "Repeat"). pub last_command: Option<(String, Value)>, /// Status / toast message for the UI. pub message: Option, /// Library files read for the open documents. pub libraries: library::Cache, } impl Default for Session { fn default() -> Self { Self::new() } } impl Session { /// A session with one new document. pub fn new() -> Self { let mut s = Self::empty(); s.docs.push(DocState::new(Document::new("Untitled"), None)); s } /// A session with no documents (the home screen). pub fn empty() -> Self { Self { docs: Vec::new(), active: 0, clipboard: Clipboard::default(), prefs: Prefs::default(), tool: tools::ToolState::default(), ui_requests: Vec::new(), journal: Vec::new(), recent_colors: Vec::new(), last_command: None, message: None, libraries: library::Cache::default(), } } pub fn with_document(doc: Document) -> Self { let mut s = Self::empty(); s.docs.push(DocState::new(doc, None)); s } pub fn ds(&self) -> Result<&DocState> { self.docs.get(self.active).ok_or(EngineError::NoDocument) } pub fn ds_mut(&mut self) -> Result<&mut DocState> { self.docs.get_mut(self.active).ok_or(EngineError::NoDocument) } pub fn doc(&self) -> Result<&Document> { Ok(self.ds()?.doc.as_ref()) } pub fn doc_mut(&mut self) -> Result<&mut Document> { Ok(self.ds_mut()?.doc_mut()) } pub fn selection(&self) -> Vec { self.ds().map(|d| d.selection.clone()).unwrap_or_default() } pub fn set_selection(&mut self, ids: Vec) { if let Ok(ds) = self.ds_mut() { ds.selection = ids; ds.clean_selection(); ds.revision += 1; } } pub fn page(&self) -> Result { Ok(self.ds()?.page) } /// Run a command by id. Mutations become one undo step; a failure or panic leaves the document /// as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if ENDS_PREVIEW.contains(&id) && let Ok(ds) = self.ds_mut() { ds.end_preview(); } let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid)); let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); if spec.history { self.last_command = Some((id.to_string(), params.clone())); } self.journal.push((id.to_string(), params.clone())); if self.journal.len() > 5000 { self.journal.drain(..1000); } } Err(_) => { // Roll back any partial edit. if let Some((doc, sel, page, uid)) = before && let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) && ds.interaction.is_none() { ds.doc = doc; ds.selection = sel; ds.page = page; } } } result } /// A version shown in place of the document is read-only: refuse an edit (it is rolled back). fn check_preview(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; if ds.preview.is_none() || Arc::ptr_eq(&ds.doc, bdoc) || id == "version.preview" { return Ok(()); } Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into())) } /// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such /// an edit (it is rolled back). fn check_instance_structure(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; if Arc::ptr_eq(&ds.doc, bdoc) { return Ok(()); } // "Repeat" runs the repeated command. let effective = match (&self.last_command, id) { (Some((last, _)), "edit.repeat") => last.as_str(), _ => id, }; library::check_read_only(effective, bdoc, &ds.doc) .and_then(|()| components::check_structure_edit(bdoc, &ds.doc)) .map_err(|m| EngineError::Disabled(id.to_string(), m)) } /// Bookkeeping after a successful command: normalise what changed, push undo, bump revision. fn after_command(&mut self, spec: &CommandSpec, before: Option<(Arc, Vec, NodeId, u64)>) { let Some((bdoc, bsel, bpage, uid)) = before else { if let Ok(ds) = self.ds_mut() { ds.revision += 1; } return; }; let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; let changed = !Arc::ptr_eq(&ds.doc, &bdoc); if changed { normalize(ds.doc_mut(), &bdoc); if spec.history && ds.interaction.is_none() { ds.history.undo.push(HistoryEntry { label: spec.label.to_string(), doc: bdoc, selection: bsel, page: bpage }); if ds.history.undo.len() > ds.history.limit.max(1) { ds.history.undo.remove(0); } ds.history.redo.clear(); } } ds.clean_selection(); ds.revision += 1; } /// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot. pub fn begin_interaction(&mut self, label: &str) -> Result<()> { let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into())); } if ds.interaction.is_none() { ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() }); } Ok(()) } /// Replace the document with `f(snapshot)` for a live preview during a gesture. pub fn preview(&mut self, f: impl FnOnce(&mut Document, &mut Vec) -> Result<()>) -> Result<()> { let ds = self.ds_mut()?; let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) }; let mut doc = (*it.doc).clone(); let mut sel = it.selection.clone(); let res = f(&mut doc, &mut sel); if res.is_ok() { normalize(&mut doc, &it.doc); ds.doc = Arc::new(doc); ds.selection = sel; ds.clean_selection(); ds.revision += 1; } res } /// Finish a gesture as one undo step (no step if nothing changed). pub fn commit_interaction(&mut self) -> Result<()> { let ds = self.ds_mut()?; let Some(it) = ds.interaction.take() else { return Ok(()) }; if !Arc::ptr_eq(&it.doc, &ds.doc) && *it.doc != *ds.doc { ds.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection, page: ds.page }); ds.history.redo.clear(); } else { ds.doc = it.doc; } ds.revision += 1; Ok(()) } /// Abandon a gesture, restoring the document. pub fn cancel_interaction(&mut self) -> Result<()> { let ds = self.ds_mut()?; if let Some(it) = ds.interaction.take() { ds.doc = it.doc; ds.selection = it.selection; ds.revision += 1; } Ok(()) } pub fn undo(&mut self) -> Result { let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled("edit.undo".into(), PREVIEW_READ_ONLY.into())); } ds.interaction = None; ds.text_edit = None; let Some(e) = ds.history.undo.pop() else { return Ok(false) }; let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page }; ds.history.redo.push(redo); ds.doc = e.doc; ds.selection = e.selection; if ds.doc.pages.contains(&e.page) { ds.page = e.page; } ds.clean_selection(); ds.revision += 1; Ok(true) } pub fn redo(&mut self) -> Result { let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled("edit.redo".into(), PREVIEW_READ_ONLY.into())); } ds.interaction = None; ds.text_edit = None; let Some(e) = ds.history.redo.pop() else { return Ok(false) }; let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page }; ds.history.undo.push(undo); ds.doc = e.doc; ds.selection = e.selection; if ds.doc.pages.contains(&e.page) { ds.page = e.page; } ds.clean_selection(); ds.revision += 1; Ok(true) } /// Queue a UI request (dialogs etc.). pub fn request_ui(&mut self, kind: &str, data: Value) { self.ui_requests.push(UiRequest { kind: kind.to_string(), data }); } /// Run several commands as one undo step. pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { let before = self.ds()?.doc.clone(); let sel = self.selection(); let page = self.page()?; let mut out = Vec::new(); let saved_len = self.ds()?.history.undo.len(); for (id, p) in cmds { match self.execute(id, p) { Ok(v) => out.push(v), Err(e) => { let ds = self.ds_mut()?; ds.doc = before; ds.selection = sel; ds.history.undo.truncate(saved_len); return Err(e); } } } let ds = self.ds_mut()?; ds.history.undo.truncate(saved_len); if !Arc::ptr_eq(&before, &ds.doc) { ds.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: sel, page }); ds.history.redo.clear(); } Ok(out) } /// A summary for status displays. pub fn status(&self) -> Value { match self.ds() { Ok(ds) => json!({ "document": ds.doc.name, "path": ds.path, "dirty": ds.is_dirty(), "page": ds.page, "selection": ds.selection, "tool": self.tool.tool.id(), "zoom": ds.view.zoom, "undo": ds.history.undo.len(), "redo": ds.history.redo.len(), "documents": self.docs.len(), }), Err(_) => json!({"documents": 0}), } } } /// Nodes whose `Arc` differs from `before` (new or edited). pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { doc.ids() .filter(|id| match (doc.node_arc(*id), before.node_arc(*id)) { (Some(a), Some(b)) => !Arc::ptr_eq(a, b), _ => true, }) .collect() } /// Keep derived state consistent after edits: text boxes resize to their content, groups wrap /// their children, instances follow their main components, auto layout re-flows. pub fn normalize(doc: &mut Document, before: &Document) { // Only what was touched since the last normalisation (edited, added or removed). let mut touched: BTreeSet = doc.take_dirty(); // A variable edit changes no layer directly, but bound layers must follow it. if touched.is_empty() && doc.variables == before.variables { return; } let m = measure::TextMeasure; // 0. Variable bindings and style links feed node values. cmd::styles::apply_bindings(doc); if doc.styles != before.styles { let changed_styles: Vec = doc.styles.iter().filter(|s| !before.styles.contains(s)).map(|s| s.id.clone()).collect(); for sid in changed_styles { cmd::styles::propagate_style(doc, &sid); } } touched.extend(doc.take_dirty()); let live = |doc: &Document, t: &BTreeSet| -> Vec { t.iter().copied().filter(|i| doc.contains(*i)).collect() }; // 0b. Fill and hug only where they can apply (a layer moved out of auto layout, a parent that // started to hug or lost auto layout): the rest becomes fixed at its current size. for id in live(doc, &touched) { figcraft_layout::coherent_sizing(doc, id); } touched.extend(doc.take_dirty()); // 1. Text auto-size. for id in live(doc, &touched) { let Some(n) = doc.node(id) else { continue }; if let Some((w, h)) = figcraft_text::auto_size(n) && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) { figcraft_layout::set_size(doc, id, w, h); } } touched.extend(doc.take_dirty()); // 2. Instances follow edited main components (and record overrides inside instances). let changed = live(doc, &touched); components::sync_after_edit(doc, before, &changed, &touched); touched.extend(doc.take_dirty()); // Parents of removed nodes (as they were before) count as touched. let removed_parents: Vec = touched.iter().filter(|i| !doc.contains(**i)).filter_map(|i| before.node(*i).and_then(|n| n.parent)).filter(|p| doc.contains(*p)).collect(); // 3. Groups / booleans wrap their children. let mut fit: BTreeSet = BTreeSet::new(); for id in live(doc, &touched).into_iter().chain(removed_parents.iter().copied()) { if doc.node(id).is_some_and(|n| n.kind.bounds_follow_children()) { fit.insert(id); } for a in doc.ancestors(id) { if doc.node(a).is_some_and(|n| n.kind.bounds_follow_children()) { fit.insert(a); } else { break; } } } let mut fit: Vec = fit.into_iter().collect(); fit.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len())); for id in fit { if doc.node(id).is_some_and(|n| n.children.is_empty()) { // Empty groups disappear, like the incumbent. let _ = doc.remove(id); } else { let _ = doc.fit_to_children(id); } } touched.extend(doc.take_dirty()); // 4. Auto layout for affected frames. let mut frames: BTreeSet = BTreeSet::new(); for id in live(doc, &touched).into_iter().chain(removed_parents) { if !doc.contains(id) { continue; } let mut chain = vec![id]; chain.extend(doc.ancestors(id)); for c in chain { if doc.node(c).is_some_and(|n| n.layout.is_some()) { frames.insert(c); } } } let mut frames: Vec = frames.into_iter().collect(); frames.sort_by_key(|id| std::cmp::Reverse(doc.ancestors(*id).len())); for f in frames { figcraft_layout::layout_frame(doc, f, &m); } doc.take_dirty(); } #[cfg(test)] mod al_drag_tests; #[cfg(test)] mod al_nesting_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] mod frame_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] mod import_export_tests; #[cfg(test)] mod library_variables_tests; #[cfg(test)] mod mask_tests; #[cfg(test)] mod nested_override_tests; #[cfg(test)] mod paint_tests; #[cfg(test)] mod proto_multi_tests; #[cfg(test)] mod rotated_select_tests; #[cfg(test)] mod section_tests; #[cfg(test)] mod shapebuilder_tests; #[cfg(test)] mod slot_tests; #[cfg(test)] mod style_tests; #[cfg(test)] mod tests; #[cfg(test)] mod text_fill_tests; #[cfg(test)] mod variable_tests; #[cfg(test)] mod variables_editor_tests; #[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests;