mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
1333 lines
47 KiB
Rust
1333 lines
47 KiB
Rust
//! 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<figcraft_doc::DocError> 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<T> = std::result::Result<T, EngineError>;
|
|
|
|
pub fn bad(cmd: &str, msg: impl Into<String>) -> 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<Document>,
|
|
pub selection: Vec<NodeId>,
|
|
pub page: NodeId,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct History {
|
|
pub undo: Vec<HistoryEntry>,
|
|
pub redo: Vec<HistoryEntry>,
|
|
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<Document>,
|
|
pub selection: Vec<NodeId>,
|
|
}
|
|
|
|
/// 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<Preedit>,
|
|
}
|
|
|
|
/// 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<Document>,
|
|
pub selection: Vec<NodeId>,
|
|
pub page: NodeId,
|
|
pub history: History,
|
|
saved_doc: Arc<Document>,
|
|
pub path: Option<String>,
|
|
pub revision: u64,
|
|
pub view: ViewState,
|
|
pub interaction: Option<Interaction>,
|
|
pub text_edit: Option<TextEdit>,
|
|
/// Hovered node (canvas hover outline).
|
|
pub hover: Option<NodeId>,
|
|
/// Vector edit mode: the vector being edited, its selected points and the edit tool.
|
|
pub vector_edit: Option<vectoredit::VectorEdit>,
|
|
pub uid: u64,
|
|
/// The file's version history, oldest first (saved in the `.figcraft` file; not undoable).
|
|
pub versions: Vec<figcraft_format::Version>,
|
|
/// The file's branches (saved in the `.figcraft` file; not undoable).
|
|
pub branches: Vec<figcraft_format::Branch>,
|
|
/// 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<VersionPreview>,
|
|
/// 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<Arc<Document>>,
|
|
/// The document as of the last version saved (or as opened): autosaves only follow changes.
|
|
checkpoint: Arc<Document>,
|
|
/// Dev Mode focus view: the frame or section shown alone on the canvas (see [`Self::focused`]).
|
|
pub focus: Option<NodeId>,
|
|
}
|
|
|
|
/// 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<Document>,
|
|
selection: Vec<NodeId>,
|
|
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<String>) -> 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<figcraft_format::Version>) -> Self {
|
|
self.versions = versions;
|
|
self
|
|
}
|
|
|
|
/// With the branches read from its file.
|
|
pub fn with_branches(mut self, branches: Vec<figcraft_format::Branch>) -> Self {
|
|
self.branches = branches;
|
|
self
|
|
}
|
|
|
|
/// The branches, for editing (the file then has unsaved changes).
|
|
pub fn branches_mut(&mut self) -> &mut Vec<figcraft_format::Branch> {
|
|
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<Document> {
|
|
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<figcraft_format::Version> {
|
|
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<Document>, selection: Vec<NodeId>, 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<Document>) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The layer the focus view shows, while it exists on the current page.
|
|
pub fn focused(&self) -> Option<NodeId> {
|
|
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<bool>,
|
|
/// Installed plugins (Plugins menu), in menu order.
|
|
#[serde(serialize_with = "plugins::persisted")]
|
|
pub plugins: Vec<plugins::InstalledPlugin>,
|
|
/// 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<String, BTreeMap<String, Value>>,
|
|
/// Recently opened or saved files, newest first (File ▸ Open recent; at most
|
|
/// [`cmd::file::MAX_RECENT`]).
|
|
pub recent_files: Vec<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(),
|
|
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<figcraft_doc::Node>)>,
|
|
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
|
|
/// Copied properties (Copy properties / Paste properties).
|
|
pub props: Option<Value>,
|
|
/// The page-space bounds of the copied selection.
|
|
pub bounds: Option<figcraft_geom::Rect>,
|
|
pub source_doc: u64,
|
|
/// The document copied from (pastes into other files follow its mains) and its file path.
|
|
pub source: Option<Arc<Document>>,
|
|
pub source_path: Option<String>,
|
|
}
|
|
|
|
/// The editing session: open documents, the current tool, clipboard and prefs.
|
|
pub struct Session {
|
|
pub docs: Vec<DocState>,
|
|
pub active: usize,
|
|
pub clipboard: Clipboard,
|
|
pub prefs: Prefs,
|
|
pub tool: tools::ToolState,
|
|
pub ui_requests: Vec<UiRequest>,
|
|
/// Command log (id + params) for actions/automation.
|
|
pub journal: Vec<(String, Value)>,
|
|
/// Recently used colours.
|
|
pub recent_colors: Vec<figcraft_color::Color>,
|
|
/// The last command executed through [`Session::execute`] (for "Repeat").
|
|
pub last_command: Option<(String, Value)>,
|
|
/// Status / toast message for the UI.
|
|
pub message: Option<String>,
|
|
/// 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<NodeId> {
|
|
self.ds().map(|d| d.selection.clone()).unwrap_or_default()
|
|
}
|
|
|
|
pub fn set_selection(&mut self, ids: Vec<NodeId>) {
|
|
if let Ok(ds) = self.ds_mut() {
|
|
ds.selection = ids;
|
|
ds.clean_selection();
|
|
ds.revision += 1;
|
|
}
|
|
}
|
|
|
|
pub fn page(&self) -> Result<NodeId> {
|
|
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<Value> {
|
|
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<Document>, Vec<NodeId>, 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<Document>, Vec<NodeId>, 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<Document>, Vec<NodeId>, 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<Document>, Vec<NodeId>, 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<Document>, selection: Vec<NodeId>, 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<String> {
|
|
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<NodeId>) -> 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<bool> {
|
|
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<bool> {
|
|
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<Vec<Value>> {
|
|
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<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
|
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<NodeId> {
|
|
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<NodeId> = 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<String> = 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<NodeId> = 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<NodeId>| -> Vec<NodeId> { 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<NodeId> =
|
|
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<NodeId> = 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<NodeId> = 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<NodeId> = 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<NodeId> = 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<Node>, bool)> = doc
|
|
.node(f)
|
|
.map(|n| std::iter::once(f).chain(n.children.iter().copied()).collect::<Vec<_>>())
|
|
.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<NodeId>) -> Option<BTreeSet<NodeId>> {
|
|
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<NodeId>) {
|
|
let ready = |d: &Document, id: NodeId| d.node(id).is_some_and(|n| n.dev_status == figcraft_doc::DevStatus::ReadyForDev);
|
|
let mut changed: BTreeSet<NodeId> = 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<NodeId> = 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 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 selection_depth_tests;
|
|
#[cfg(test)]
|
|
mod shapebuilder_strokes_tests;
|
|
#[cfg(test)]
|
|
mod shapebuilder_tests;
|
|
#[cfg(test)]
|
|
mod slot_tests;
|
|
#[cfg(test)]
|
|
mod style_tests;
|
|
#[cfg(test)]
|
|
mod svg_import_tests;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
#[cfg(test)]
|
|
mod text_fill_tests;
|
|
#[cfg(test)]
|
|
mod text_style_picker_tests;
|
|
#[cfg(test)]
|
|
mod tiff_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;
|