figcraft/crates/engine/src/lib.rs

772 lines
26 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 catalog;
pub mod cmd;
pub mod codegen;
pub mod components;
pub mod drop;
pub mod guard;
pub mod inspect;
pub mod measure;
pub mod params;
pub mod snap;
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<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,
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<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 version history changed since the last save.
versions_changed: bool,
}
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();
}
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 {
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,
};
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
}
/// 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
}
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved_doc) || self.versions_changed
}
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<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 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>,
}
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,
}
}
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()))?;
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));
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
}
/// Instances can't gain, lose or reorder layers: 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(());
}
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);
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.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);
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<bool> {
let ds = self.ds_mut()?;
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<bool> {
let ds = self.ds_mut()?;
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<Vec<Value>> {
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<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.
cmd::styles::apply_bindings(doc);
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());
let live = |doc: &Document, t: &BTreeSet<NodeId>| -> Vec<NodeId> { t.iter().copied().filter(|i| doc.contains(*i)).collect() };
// 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<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 affected frames.
let mut frames: BTreeSet<NodeId> = 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<NodeId> = 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 frame_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod section_tests;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod text_fill_tests;
#[cfg(test)]
mod variable_tests;
#[cfg(test)]
mod version_tests;