//! 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 al_keys; pub mod annotations; pub mod arc_handles; pub mod booleans; pub mod branches; pub mod catalog; pub mod cmd; pub mod code_connect; pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; pub mod devcompare; pub mod devinspect; pub mod distance; pub mod drop; pub mod guard; pub mod guides; pub mod inspect; pub mod invite_links; pub mod keymap; pub mod library; pub mod library_server; pub mod library_swap; pub mod live; pub mod measure; pub mod params; pub mod plugins; pub mod proto_run; pub mod shapebuilder; pub mod snap; pub mod synthetic; pub mod synthetic_ds; pub mod textedit; pub mod textpreview; pub mod tools; pub mod vectoredit; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_collab; pub use figcraft_doc as doc; pub use figcraft_svg::looks_like_svg; pub use tools::{PointerEvent, PointerKind, ToolId}; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use figcraft_doc::{Document, Node, 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, /// Outline mode for the selected layers only (the rest draws normally). pub outlines_selection: bool, /// Outline the masks (they don't draw themselves). pub mask_outlines: bool, pub pixel_preview: bool, /// Pixel preview resolution: rendered pixels per page pixel (1 or 2). pub pixel_scale: u8, pub show_slices: bool, pub comments: bool, pub frame_outlines: bool, /// Soft-proof sRGB: the canvas marks the colours of a Display P3 file that sRGB can't show. pub proof_srgb: 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, outlines_selection: false, mask_outlines: false, pixel_preview: false, pixel_scale: 1, show_slices: false, comments: true, frame_outlines: false, proof_srgb: 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 file's branches (saved in the `.figcraft` file; not undoable). pub branches: Vec, /// This tab shows a branch: the `uid` of its main file's tab and the branch id. Its edits go /// into the main file's branch (and are saved with the main file). pub branch_of: Option<(u64, String)>, /// The version history or the branches changed since the last save. versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, /// The document under a live text preview (a hovered text style or font instance, a dragged /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, /// The document as of the last version saved (or as opened): autosaves only follow changes. checkpoint: Arc, /// Dev Mode focus view: the frame or section shown alone on the canvas (see [`Self::focused`]). pub focus: Option, } /// 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(); } doc.link_layer_text_styles(); 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(), branches: Vec::new(), branch_of: None, versions_changed: false, preview: None, text_preview: None, focus: 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 } /// With the branches read from its file. pub fn with_branches(mut self, branches: Vec) -> Self { self.branches = branches; self } /// The branches, for editing (the file then has unsaved changes). pub fn branches_mut(&mut self) -> &mut Vec { self.versions_changed = true; &mut self.branches } /// The document itself, not a version or live text preview shown in its place. pub fn document_itself(&self) -> &Arc { self.preview.as_ref().map(|p| &p.doc).or(self.text_preview.as_ref()).unwrap_or(&self.doc) } /// 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(); } /// Push a snapshot undo step: the document was `before` (shown with `selection` on `page`). pub(crate) fn push_undo(&mut self, label: &str, before: Arc, selection: Vec, page: NodeId) { self.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection, page }); if self.history.undo.len() > self.history.limit.max(1) { self.history.undo.remove(0); } self.history.redo.clear(); } pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(self.document_itself(), &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 } /// Shown as having unsaved changes until it is saved (a document recovered after a crash). pub fn unsaved(mut self) -> Self { self.saved_doc = Arc::new(Document::new(self.doc.name.clone())); self } 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; } } } /// The layer the focus view shows, while it exists on the current page. pub fn focused(&self) -> Option { self.focus.filter(|f| self.doc.page_of(*f) == Some(self.page)) } /// 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, /// The interface language: a language code ("en", "es") or "system" (the OS or browser /// language). Languages without a catalog show English. pub language: String, /// The name comments are signed with (and whose comments count as yours). pub user_name: String, /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, /// Installed plugins (Plugins menu), in menu order. #[serde(serialize_with = "plugins::persisted")] pub plugins: Vec, /// Plugins' own settings (`setting_set`), by plugin id, then key: JSON values, capped per /// plugin. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub plugin_settings: BTreeMap>, /// Recently opened or saved files, newest first (File ▸ Open recent; at most /// [`cmd::file::MAX_RECENT`]). pub recent_files: Vec, } 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(), language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), recent_files: Vec::new(), } } } /// 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 document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, } /// 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, /// Plugin modules loaded in this session and the last plugin run. pub plugins: plugins::Runtime, /// The live session (collaboration): status, peers, presence. pub live: live::Live, } 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(), plugins: plugins::Runtime::default(), live: live::Live::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()))?; branches::sync(self); if textpreview::COMMANDS.contains(&id) { // A live preview is no edit: no undo step, normalisation or rollback here. let r = guard::run(id, || (spec.run)(self, params)); if r.is_err() { textpreview::end(self); } return r; } // Every other command acts on the document itself, not on a live text preview. textpreview::end(self); 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)) .and_then(|v| self.check_live_access(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())) } /// A live session's document you may only view (or that is on its way to a server) is /// read-only: refuse an edit (it is rolled back). fn check_live_access(&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(()) }; match self.live_read_only(*uid) { Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())), _ => Ok(()), } } /// 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); } let edit = changed && ds.interaction.is_none(); ds.clean_selection(); ds.revision += 1; if edit { self.record(uid, spec.history.then_some(spec.label), bdoc, bsel, bpage); } } /// Record an edit of document `uid` (it was `before`, shown with `selection` on `page`) as one /// undo step called `label` (`None`: not undoable). On a live session's document the edit is /// synced and the step is the user's own ([`live`]); otherwise it is a snapshot of `before`. pub(crate) fn record(&mut self, uid: u64, label: Option<&str>, before: Arc, selection: Vec, page: NodeId) { if self.live_doc(uid) { return self.live_edit(label); } if let (Some(label), Some(ds)) = (label, self.docs.iter_mut().find(|d| d.uid == uid)) { ds.push_undo(label, before, selection, page); } } /// How many undo and redo steps the active document has (the user's own in a live session). pub fn undo_depth(&self) -> (usize, usize) { match self.ds() { Ok(ds) if self.live_doc(ds.uid) => self.live.client().map_or((0, 0), figcraft_collab::Client::history_len), Ok(ds) => (ds.history.undo.len(), ds.history.redo.len()), Err(_) => (0, 0), } } /// The labels of the active document's last `n` undo steps, the next one first. pub fn undo_labels(&self, n: usize) -> Vec { match self.ds() { Ok(ds) if self.live_doc(ds.uid) => { self.live.client().map(|c| c.undo_labels().rev().take(n).map(str::to_string).collect()).unwrap_or_default() } Ok(ds) => ds.history.undo.iter().rev().take(n).map(|h| h.label.clone()).collect(), Err(_) => Vec::new(), } } /// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot. pub fn begin_interaction(&mut self, label: &str) -> Result<()> { textpreview::end(self); if let Some(why) = self.active_read_only() { return Err(EngineError::Disabled(label.to_string(), why.into())); } 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); // The same preview again (a handle held still) keeps the document: a new one would // re-render the canvas for nothing. if doc == *ds.doc && sel == ds.selection { return res; } 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(()) }; let (uid, page) = (ds.uid, ds.page); ds.revision += 1; if !Arc::ptr_eq(&it.doc, &ds.doc) && *it.doc != *ds.doc { self.record(uid, Some(&it.label), it.doc, it.selection, page); } else { ds.doc = it.doc; } 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 { textpreview::end(self); let live = self.ds().is_ok_and(|d| self.live_doc(d.uid)); let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled("edit.undo".into(), PREVIEW_READ_ONLY.into())); } if let Some(it) = ds.interaction.take() && live { // The gesture in progress is dropped (it was never synced). ds.doc = it.doc; } ds.text_edit = None; if live { return Ok(self.live_travel(true)); } 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 { textpreview::end(self); let live = self.ds().is_ok_and(|d| self.live_doc(d.uid)); let ds = self.ds_mut()?; if ds.preview.is_some() { return Err(EngineError::Disabled("edit.redo".into(), PREVIEW_READ_ONLY.into())); } if let Some(it) = ds.interaction.take() && live { ds.doc = it.doc; } ds.text_edit = None; if live { return Ok(self.live_travel(false)); } 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> { self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect()) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their /// own, and when it fails the document, selection, page and history are put back. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { let ds = self.ds_mut()?; let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid); // The steps inside are dropped again below, so they mustn't push the oldest ones out. let limit = std::mem::replace(&mut ds.history.limit, usize::MAX); self.live_hold(uid, true); let result = f(self); self.live_hold(uid, false); let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result }; ds.history.limit = limit; ds.history.undo.truncate(saved_len); if result.is_err() { ds.doc = before; ds.selection = sel; ds.page = page; } else if !Arc::ptr_eq(&before, &ds.doc) { self.record(uid, Some(label), before, sel, page); } result } /// 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": self.undo_depth().0, "redo": self.undo_depth().1, "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. let scope = binding_scope(doc, before, &touched); cmd::styles::apply_bindings_to(doc, scope.as_ref()); 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()); // Text chars edited away from their text style unlink from it. let live_now: Vec = touched.iter().copied().filter(|i| doc.contains(*i)).collect(); cmd::styles::sync_text_styles(doc, &live_now); 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); // Rebuilt copies took their mains' values: their bindings resolve in their own modes. let synced = doc.take_dirty(); if !synced.is_empty() { cmd::styles::apply_bindings_to(doc, Some(&synced)); touched.extend(synced); } 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 frames where something that sizes or places layers changed. let mut frames: BTreeSet = BTreeSet::new(); let moved = live(doc, &touched).into_iter().filter(|id| affects_layout(before, doc, *id)); for id in moved.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 { // Layers the layout leaves where they were stay untouched. let kept: Vec<(Arc, bool)> = doc .node(f) .map(|n| std::iter::once(f).chain(n.children.iter().copied()).collect::>()) .unwrap_or_default() .into_iter() .filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))) .collect(); figcraft_layout::layout_frame(doc, f, &m); for (a, was_dirty) in kept { doc.keep_if_unchanged(a, was_dirty); } } touched.extend(doc.take_dirty()); // 5. Ready-for-dev layers whose contents changed show "Changed". mark_dev_changes(doc, before, &touched); doc.take_dirty(); // 5. Comment pins follow their layers to other pages and stay put when the layers are deleted. cmd::comment::follow_anchors(doc, before); } /// Whether layer `id` changed (since `before`) in a way that can move or resize layers in an auto /// layout frame around it: new, resized, moved, shown or hidden, restructured, other text or /// layout settings. fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool { let (Some(a), Some(b)) = (before.node(id), doc.node(id)) else { return true }; a.width != b.width || a.height != b.height || a.transform != b.transform || a.visible != b.visible || a.parent != b.parent || a.children != b.children || a.layout != b.layout || a.child_layout != b.child_layout || a.stroke != b.stroke || a.strokes.len() != b.strokes.len() || a.kind != b.kind } /// The layers whose variable bindings an edit may have changed: every bound layer when a variable /// changed, else the touched layers and everything below those that moved, are new or chose /// another variable mode (modes are inherited). fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> { if doc.variables != before.variables { return None; } let mut out = BTreeSet::new(); for &id in touched { let Some(n) = doc.node(id) else { continue }; let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent); if inherited { out.extend(doc.subtree(id)); } else { out.insert(id); } } Some(out) } /// Flag the frames and sections marked ready for dev (before this edit) that contain a layer whose /// design `touched` changed (added, removed or edited; the handoff status itself and hiding a /// section's contents don't count). fn mark_dev_changes(doc: &mut Document, before: &Document, touched: &BTreeSet) { let ready = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev); let mut changed: BTreeSet = BTreeSet::new(); for &id in touched { // Ready-for-dev frames around it first (the cheap test). let mut chain = vec![id]; chain.extend(if doc.contains(id) { doc.ancestors(id) } else { before.ancestors(id) }); chain.retain(|m| ready(before, *m) && ready(doc, *m)); if chain.is_empty() { continue; } let differs = match (before.node(id), doc.node(id)) { (Some(a), Some(b)) => !a.same_design(b), (None, None) => false, _ => true, }; if differs { changed.extend(chain); } } for m in changed { if doc.node(m).is_some_and(|n| !n.dev_changed) { // Its first change since it was marked: keep what it looked like (for Compare changes). if let Some(snap) = before.snapshot_subtree(m) { doc.dev_snapshots.insert(m, Arc::new(snap)); } if let Some(n) = doc.node_mut(m) { n.dev_changed = true; } } } // Snapshots last until the change is resolved, the layer is marked again or it's deleted. if !doc.dev_snapshots.is_empty() { let keep = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev && n.dev_changed); let stale: Vec = doc.dev_snapshots.keys().copied().filter(|id| !keep(doc, *id)).collect(); for id in stale { doc.dev_snapshots.remove(&id); } } } #[cfg(test)] mod a11y_keys_tests; #[cfg(test)] mod al_behaviour_tests; #[cfg(test)] mod al_drag_tests; #[cfg(test)] mod al_keys_tests; #[cfg(test)] mod al_nesting_tests; #[cfg(test)] mod annotation_categories_tests; #[cfg(test)] mod annotations_tests; #[cfg(test)] mod arc_fields_tests; #[cfg(test)] mod arc_handles_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] mod booleans_tests; #[cfg(test)] mod branch_tests; #[cfg(test)] mod brush_tests; #[cfg(test)] mod canvas_fixes_tests; #[cfg(test)] mod code_connect_tests; #[cfg(test)] mod collab_ids_tests; #[cfg(test)] mod collab_live_tests; #[cfg(test)] mod collab_tests; #[cfg(test)] mod collab_undo_tests; #[cfg(test)] mod comments_polish_tests; #[cfg(test)] mod component_codegen_tests; #[cfg(test)] mod components_scale_tests; #[cfg(test)] mod devcompare_tests; #[cfg(test)] mod devfocus_tests; #[cfg(test)] mod devinspect_tests; #[cfg(test)] mod draw_tools_tests; #[cfg(test)] mod effects_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; #[cfg(test)] mod frame_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] mod import_export_tests; #[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; #[cfg(test)] mod library_variables_tests; #[cfg(test)] mod live_access_tests; #[cfg(test)] mod live_tests; #[cfg(test)] mod mask_tests; #[cfg(test)] mod measure_tests; #[cfg(test)] mod menu_gaps_tests; #[cfg(test)] mod nested_override_tests; #[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; #[cfg(test)] mod pdf_print_tests; #[cfg(test)] mod pixel_preview_tests; #[cfg(test)] mod plugin_panels_tests; #[cfg(test)] mod plugins_tests; #[cfg(test)] mod progressive_blur_tests; #[cfg(test)] mod proto_conditional_tests; #[cfg(test)] mod proto_multi_tests; #[cfg(test)] mod proto_polish_tests; #[cfg(test)] mod proto_present_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] mod proto_run_tests; #[cfg(test)] mod retained_walk_tests; #[cfg(test)] mod rotated_guides_tests; #[cfg(test)] mod rotated_select_tests; #[cfg(test)] mod scale_tool_tests; #[cfg(test)] mod section_contents_tests; #[cfg(test)] mod section_tests; #[cfg(test)] mod select_area_tests; #[cfg(test)] mod selection_behaviour_tests; #[cfg(test)] mod selection_depth_tests; #[cfg(test)] mod shapebuilder_strokes_tests; #[cfg(test)] mod shapebuilder_tests; #[cfg(test)] mod slot_tests; #[cfg(test)] mod stroke_style_tests; #[cfg(test)] mod style_tests; #[cfg(test)] mod svg_import_tests; #[cfg(test)] mod tests; #[cfg(test)] mod text_behaviour_tests; #[cfg(test)] mod text_fill_tests; #[cfg(test)] mod text_motion_tests; #[cfg(test)] mod text_style_picker_tests; #[cfg(test)] mod tiff_tests; #[cfg(test)] mod transform_behaviour_tests; #[cfg(test)] mod variable_tests; #[cfg(test)] mod variables_editor_tests; #[cfg(test)] mod variables_io_tests; #[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests;