mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
- Edit-mode toolbar (Move V, Pen P, Bend hold Cmd, Paint bucket Shift+B, Lasso Q, Cut X, Done) replaces the tools while a vector is edited. - Pen in edit mode continues open ends, branches from any point (vector networks), adds points on segments and closes paths into fill regions; dragging pulls out mirrored handles. - Paint bucket fills or unfills any closed area: faces of the network (half-edge walk, nested pieces become holes), with a hatched preview of the area under the pointer. - Bend tool / Cmd: bend segments, pull handles out of a point, click a smooth point to sharpen it. - Lasso and marquee point selection, segment selection (shift adds), cut at a segment or a point. - Per-point handle mirroring (none / angle / angle and length, inferred for existing files), Alt-drag breaks it; smooth/sharp points; join and merge selected points (Cmd+J, Shift+Cmd+J). - Delete opens the path, Shift+Delete heals it; arrows nudge points; Cmd+A selects all points. - Design panel shows the selected points' position, corner radius and mirroring. - Every gesture is one undo step; edit mode ends when its layer is undone away. - Paths that end on their start without a ClosePath (ellipse outlines) stay closed and filled. - New commands (vector.edit.tool/pen/paint/cut/lasso/mirroring/smooth/position/nudge/connect/...) for the CLI, control channel and MCP; docs updated. - Clippy: simplify a few boolean expressions flagged by the current toolchain. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
737 lines
26 KiB
Rust
737 lines
26 KiB
Rust
//! The FigCraft document model.
|
|
//!
|
|
//! A [`Document`] holds pages; each page is the root of a tree of [`Node`]s (frames, groups, shapes,
|
|
//! text, components, instances…). Nodes are stored by id in a map of `Arc`s, so cloning a document
|
|
//! (an undo snapshot) only copies pointers, and editing one node copies only that node.
|
|
//!
|
|
//! Everything here is pure data plus queries and structural edits; commands, history and rendering
|
|
//! live in higher crates.
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
|
|
pub mod comments;
|
|
pub mod geometry;
|
|
pub mod node;
|
|
pub mod props;
|
|
pub mod proto;
|
|
pub mod styles;
|
|
pub mod text;
|
|
pub mod vector;
|
|
pub mod vector_faces;
|
|
|
|
pub use comments::*;
|
|
pub use figcraft_color as color;
|
|
pub use figcraft_geom as geom;
|
|
pub use node::*;
|
|
pub use props::*;
|
|
pub use styles::*;
|
|
pub use text::*;
|
|
pub use vector::*;
|
|
pub use vector_faces::Face;
|
|
|
|
use figcraft_color::Color;
|
|
use figcraft_geom::{Affine, Point, Rect};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
/// Stable node identifier, unique within a document.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct NodeId(pub u64);
|
|
|
|
impl std::fmt::Display for NodeId {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl std::str::FromStr for NodeId {
|
|
type Err = std::num::ParseIntError;
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
// Accept "12" and Figma-style "0:12".
|
|
let t = s.trim();
|
|
let t = t.rsplit(':').next().unwrap_or(t);
|
|
t.parse::<u64>().map(NodeId)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, PartialEq)]
|
|
pub enum DocError {
|
|
#[error("no such node {0}")]
|
|
NoNode(NodeId),
|
|
#[error("{0} can't contain children")]
|
|
NotContainer(NodeId),
|
|
#[error("moving {0} into {1} would create a cycle")]
|
|
Cycle(NodeId, NodeId),
|
|
#[error("{0}")]
|
|
Invalid(String),
|
|
}
|
|
|
|
pub type DocResult<T> = Result<T, DocError>;
|
|
|
|
/// The set of touched node ids. Never part of equality or serialisation.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Dirty(pub std::collections::BTreeSet<NodeId>);
|
|
|
|
impl PartialEq for Dirty {
|
|
fn eq(&self, _: &Self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Raw image bytes (encoded PNG/JPEG/…) kept by content hash.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct ImageData {
|
|
pub bytes: Arc<Vec<u8>>,
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
/// A whole design file.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Document {
|
|
pub name: String,
|
|
/// Page ids in order.
|
|
pub pages: Vec<NodeId>,
|
|
nodes: imbl::OrdMap<NodeId, Arc<Node>>,
|
|
/// Nodes touched since the last [`take_dirty`](Self::take_dirty) (edited, added or removed).
|
|
#[serde(skip)]
|
|
dirty: Dirty,
|
|
next_id: u64,
|
|
#[serde(default)]
|
|
pub styles: Vec<Style>,
|
|
#[serde(default)]
|
|
pub variables: Variables,
|
|
/// Images by content hash; serialized separately by the format crate.
|
|
#[serde(skip)]
|
|
pub images: BTreeMap<String, ImageData>,
|
|
/// The page shown when the file opens.
|
|
#[serde(default)]
|
|
pub current_page: Option<NodeId>,
|
|
/// Colours used recently / saved in "Document colors".
|
|
#[serde(default)]
|
|
pub document_colors: Vec<Color>,
|
|
/// Comment threads on any page.
|
|
#[serde(default)]
|
|
pub comments: Vec<Comment>,
|
|
}
|
|
|
|
impl Default for Document {
|
|
fn default() -> Self {
|
|
Self::new("Untitled")
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// A new file with one empty page ("Page 1").
|
|
pub fn new(name: impl Into<String>) -> Self {
|
|
let mut doc = Self {
|
|
name: name.into(),
|
|
pages: Vec::new(),
|
|
nodes: imbl::OrdMap::new(),
|
|
dirty: Dirty::default(),
|
|
next_id: 1,
|
|
styles: Vec::new(),
|
|
variables: Variables::default(),
|
|
images: BTreeMap::new(),
|
|
current_page: None,
|
|
document_colors: Vec::new(),
|
|
comments: Vec::new(),
|
|
};
|
|
let p = doc.add_page("Page 1");
|
|
doc.current_page = Some(p);
|
|
doc
|
|
}
|
|
|
|
/// Node ids touched since the last call (edited, added or removed), clearing the record.
|
|
pub fn take_dirty(&mut self) -> std::collections::BTreeSet<NodeId> {
|
|
std::mem::take(&mut self.dirty.0)
|
|
}
|
|
|
|
/// Node ids touched so far (without clearing).
|
|
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
|
|
&self.dirty.0
|
|
}
|
|
|
|
/// A fresh unused id.
|
|
pub fn alloc_id(&mut self) -> NodeId {
|
|
let id = NodeId(self.next_id);
|
|
self.next_id = self.next_id.saturating_add(1);
|
|
id
|
|
}
|
|
|
|
pub fn node(&self, id: NodeId) -> Option<&Node> {
|
|
self.nodes.get(&id).map(Arc::as_ref)
|
|
}
|
|
|
|
pub fn node_arc(&self, id: NodeId) -> Option<&Arc<Node>> {
|
|
self.nodes.get(&id)
|
|
}
|
|
|
|
/// Mutable access (copy-on-write when a snapshot shares the node).
|
|
pub fn node_mut(&mut self, id: NodeId) -> Option<&mut Node> {
|
|
let n = self.nodes.get_mut(&id).map(Arc::make_mut);
|
|
if n.is_some() {
|
|
self.dirty.0.insert(id);
|
|
}
|
|
n
|
|
}
|
|
|
|
pub fn get(&self, id: NodeId) -> DocResult<&Node> {
|
|
self.node(id).ok_or(DocError::NoNode(id))
|
|
}
|
|
|
|
pub fn get_mut(&mut self, id: NodeId) -> DocResult<&mut Node> {
|
|
self.node_mut(id).ok_or(DocError::NoNode(id))
|
|
}
|
|
|
|
pub fn contains(&self, id: NodeId) -> bool {
|
|
self.nodes.contains_key(&id)
|
|
}
|
|
|
|
pub fn node_count(&self) -> usize {
|
|
self.nodes.len()
|
|
}
|
|
|
|
/// Every node id (pages included), ascending.
|
|
pub fn ids(&self) -> impl Iterator<Item = NodeId> + '_ {
|
|
self.nodes.keys().copied()
|
|
}
|
|
|
|
pub fn nodes(&self) -> impl Iterator<Item = &Node> + '_ {
|
|
self.nodes.values().map(Arc::as_ref)
|
|
}
|
|
|
|
/// Add a page at the end.
|
|
pub fn add_page(&mut self, name: &str) -> NodeId {
|
|
let id = self.alloc_id();
|
|
let node = Node::new(
|
|
id,
|
|
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
|
name,
|
|
0.0,
|
|
0.0,
|
|
);
|
|
self.nodes.insert(id, Arc::new(node));
|
|
self.dirty.0.insert(id);
|
|
self.pages.push(id);
|
|
id
|
|
}
|
|
|
|
/// The page a node is on (a page is on itself).
|
|
pub fn page_of(&self, id: NodeId) -> Option<NodeId> {
|
|
let mut cur = id;
|
|
for _ in 0..10_000 {
|
|
let n = self.node(cur)?;
|
|
if n.is_page() {
|
|
return Some(cur);
|
|
}
|
|
cur = n.parent?;
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn first_page(&self) -> Option<NodeId> {
|
|
self.pages.first().copied()
|
|
}
|
|
|
|
/// Insert `node` (its id must come from [`alloc_id`](Self::alloc_id)) as a child of `parent` at
|
|
/// `index` (None = on top).
|
|
pub fn insert(&mut self, mut node: Node, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
|
let p = self.get(parent)?;
|
|
if !p.kind.is_container() {
|
|
return Err(DocError::NotContainer(parent));
|
|
}
|
|
let id = node.id;
|
|
if self.nodes.contains_key(&id) {
|
|
return Err(DocError::Invalid(format!("node {id} already exists")));
|
|
}
|
|
if id.0 >= self.next_id {
|
|
self.next_id = id.0.saturating_add(1);
|
|
}
|
|
node.parent = Some(parent);
|
|
self.nodes.insert(id, Arc::new(node));
|
|
self.dirty.0.insert(id);
|
|
let p = self.get_mut(parent)?;
|
|
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
|
p.children.insert(at, id);
|
|
Ok(id)
|
|
}
|
|
|
|
/// Create a node of `kind` under `parent`, positioned at `(x, y)` in the parent's space.
|
|
pub fn create(&mut self, parent: NodeId, kind: NodeKind, name: &str, x: f64, y: f64, w: f64, h: f64) -> DocResult<NodeId> {
|
|
let id = self.alloc_id();
|
|
let mut node = Node::new(id, kind, name, w, h);
|
|
node.transform = Affine::translate((x, y));
|
|
self.insert(node, parent, None)
|
|
}
|
|
|
|
/// Whether `ancestor` is `id` or one of its ancestors.
|
|
pub fn is_ancestor(&self, ancestor: NodeId, id: NodeId) -> bool {
|
|
let mut cur = Some(id);
|
|
let mut guard = 0;
|
|
while let Some(c) = cur {
|
|
if c == ancestor {
|
|
return true;
|
|
}
|
|
guard += 1;
|
|
if guard > 10_000 {
|
|
return false;
|
|
}
|
|
cur = self.node(c).and_then(|n| n.parent);
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Ancestors from the parent up to (and including) the page.
|
|
pub fn ancestors(&self, id: NodeId) -> Vec<NodeId> {
|
|
let mut out = Vec::new();
|
|
let mut cur = self.node(id).and_then(|n| n.parent);
|
|
while let Some(c) = cur {
|
|
if out.len() > 10_000 {
|
|
break;
|
|
}
|
|
out.push(c);
|
|
cur = self.node(c).and_then(|n| n.parent);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `id` and all its descendants, pre-order.
|
|
pub fn subtree(&self, id: NodeId) -> Vec<NodeId> {
|
|
let mut out = Vec::new();
|
|
let mut stack = vec![id];
|
|
while let Some(c) = stack.pop() {
|
|
if out.len() > 5_000_000 {
|
|
break;
|
|
}
|
|
let Some(n) = self.node(c) else { continue };
|
|
out.push(c);
|
|
stack.extend(n.children.iter().rev());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Remove a node and its subtree. Pages can only be removed while another page remains.
|
|
pub fn remove(&mut self, id: NodeId) -> DocResult<()> {
|
|
let node = self.get(id)?;
|
|
let parent = node.parent;
|
|
if node.is_page() {
|
|
if self.pages.len() <= 1 {
|
|
return Err(DocError::Invalid("a file needs at least one page".into()));
|
|
}
|
|
self.pages.retain(|p| *p != id);
|
|
if self.current_page == Some(id) {
|
|
self.current_page = self.pages.first().copied();
|
|
}
|
|
}
|
|
if let Some(parent) = parent
|
|
&& let Some(p) = self.node_mut(parent)
|
|
{
|
|
p.children.retain(|c| *c != id);
|
|
}
|
|
for d in self.subtree(id) {
|
|
self.nodes.remove(&d);
|
|
self.dirty.0.insert(d);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Index of `id` among its siblings.
|
|
pub fn index_in_parent(&self, id: NodeId) -> Option<usize> {
|
|
let parent = self.node(id)?.parent?;
|
|
self.node(parent)?.children.iter().position(|c| *c == id)
|
|
}
|
|
|
|
/// Move `id` under `new_parent` at `index` (None = top), keeping its world position.
|
|
pub fn reparent(&mut self, id: NodeId, new_parent: NodeId, index: Option<usize>) -> DocResult<()> {
|
|
if self.is_ancestor(id, new_parent) {
|
|
return Err(DocError::Cycle(id, new_parent));
|
|
}
|
|
let np = self.get(new_parent)?;
|
|
if !np.kind.is_container() {
|
|
return Err(DocError::NotContainer(new_parent));
|
|
}
|
|
let node = self.get(id)?;
|
|
if node.is_page() {
|
|
return Err(DocError::Invalid("pages can't be moved into nodes".into()));
|
|
}
|
|
let world = self.world_transform(id);
|
|
let old_parent = node.parent;
|
|
let old_index = self.index_in_parent(id);
|
|
if let Some(op) = old_parent
|
|
&& let Some(p) = self.node_mut(op)
|
|
{
|
|
p.children.retain(|c| *c != id);
|
|
}
|
|
let parent_world = self.world_transform(new_parent);
|
|
let np = self.get_mut(new_parent)?;
|
|
let mut at = index.unwrap_or(np.children.len());
|
|
// Moving within the same parent: the removal shifted later indices.
|
|
if old_parent == Some(new_parent)
|
|
&& let (Some(oi), Some(_)) = (old_index, index)
|
|
&& oi < at
|
|
{
|
|
at = at.saturating_sub(1);
|
|
}
|
|
let at = at.min(np.children.len());
|
|
np.children.insert(at, id);
|
|
let n = self.get_mut(id)?;
|
|
n.parent = Some(new_parent);
|
|
n.transform = parent_world.inverse() * world;
|
|
Ok(())
|
|
}
|
|
|
|
/// Reorder `id` within its parent to `index`.
|
|
pub fn reorder(&mut self, id: NodeId, index: usize) -> DocResult<()> {
|
|
let parent = self.get(id)?.parent.ok_or(DocError::Invalid("page".into()))?;
|
|
let p = self.get_mut(parent)?;
|
|
p.children.retain(|c| *c != id);
|
|
let at = index.min(p.children.len());
|
|
p.children.insert(at, id);
|
|
Ok(())
|
|
}
|
|
|
|
/// Transform from `id`'s local space to its page.
|
|
pub fn world_transform(&self, id: NodeId) -> Affine {
|
|
let mut xf = Affine::IDENTITY;
|
|
let mut cur = Some(id);
|
|
let mut guard = 0;
|
|
while let Some(c) = cur {
|
|
let Some(n) = self.node(c) else { break };
|
|
if n.is_page() {
|
|
break;
|
|
}
|
|
xf = n.transform * xf;
|
|
cur = n.parent;
|
|
guard += 1;
|
|
if guard > 10_000 {
|
|
break;
|
|
}
|
|
}
|
|
xf
|
|
}
|
|
|
|
/// Set `id`'s transform so that its world transform becomes `world`.
|
|
pub fn set_world_transform(&mut self, id: NodeId, world: Affine) -> DocResult<()> {
|
|
let parent = self.get(id)?.parent;
|
|
let pw = parent.map(|p| self.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
|
self.get_mut(id)?.transform = pw.inverse() * world;
|
|
Ok(())
|
|
}
|
|
|
|
/// Axis-aligned bounds of the node's box in page coordinates.
|
|
pub fn world_bounds(&self, id: NodeId) -> Option<Rect> {
|
|
let n = self.node(id)?;
|
|
if n.is_page() {
|
|
return self.page_content_bounds(id);
|
|
}
|
|
Some(figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect()))
|
|
}
|
|
|
|
/// Bounds of everything painted by a node, effects and strokes included (page coordinates).
|
|
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
|
let n = self.node(id)?;
|
|
let reach = n.paint_reach();
|
|
let mut r = figcraft_geom::transform_rect_bbox(self.world_transform(id), n.local_rect().inflate(reach, reach));
|
|
if !(n.clip && n.kind.is_frame_like()) || n.kind.bounds_follow_children() {
|
|
for c in &n.children {
|
|
if let Some(cr) = self.world_render_bounds(*c) {
|
|
r = r.union(cr);
|
|
}
|
|
}
|
|
}
|
|
Some(r)
|
|
}
|
|
|
|
/// Union of the top-level nodes' bounds on a page.
|
|
pub fn page_content_bounds(&self, page: NodeId) -> Option<Rect> {
|
|
let p = self.node(page)?;
|
|
p.children.iter().filter_map(|c| self.world_bounds(*c)).reduce(|a, b| a.union(b))
|
|
}
|
|
|
|
/// Union of several nodes' world bounds.
|
|
pub fn bounds_of(&self, ids: &[NodeId]) -> Option<Rect> {
|
|
ids.iter().filter_map(|i| self.world_bounds(*i)).reduce(|a, b| a.union(b))
|
|
}
|
|
|
|
/// Resize a group/boolean so its box tightly wraps its children (children keep world position).
|
|
pub fn fit_to_children(&mut self, id: NodeId) -> DocResult<()> {
|
|
let n = self.get(id)?;
|
|
if n.children.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let world = self.world_transform(id);
|
|
let inv = world.inverse();
|
|
// Children bounds in the group's current local space.
|
|
let mut local: Option<Rect> = None;
|
|
for c in &n.children {
|
|
let Some(cn) = self.node(*c) else { continue };
|
|
let r = figcraft_geom::transform_rect_bbox(inv * self.world_transform(*c), cn.local_rect());
|
|
local = Some(local.map_or(r, |l| l.union(r)));
|
|
}
|
|
let Some(local) = local else { return Ok(()) };
|
|
let children = n.children.clone();
|
|
let child_worlds: Vec<(NodeId, Affine)> = children.iter().map(|c| (*c, self.world_transform(*c))).collect();
|
|
let shift = Affine::translate((local.x0, local.y0));
|
|
let nw = world * shift;
|
|
{
|
|
let g = self.get_mut(id)?;
|
|
g.transform *= shift;
|
|
g.width = local.width();
|
|
g.height = local.height();
|
|
}
|
|
let ninv = nw.inverse();
|
|
for (c, w) in child_worlds {
|
|
if let Some(cn) = self.node_mut(c) {
|
|
cn.transform = ninv * w;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Fit every group/boolean ancestor of `id` (innermost first).
|
|
pub fn fit_ancestors(&mut self, id: NodeId) {
|
|
for a in self.ancestors(id) {
|
|
let follows = self.node(a).is_some_and(|n| n.kind.bounds_follow_children());
|
|
if follows {
|
|
let _ = self.fit_to_children(a);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Deep-copy `id`'s subtree under `parent` (at `index`), returning the new root id. The copy
|
|
/// keeps the original's local transform; ids are fresh.
|
|
pub fn duplicate_into(&mut self, id: NodeId, parent: NodeId, index: Option<usize>) -> DocResult<NodeId> {
|
|
let mut map = BTreeMap::new();
|
|
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
|
|
for n in &src_nodes {
|
|
let nid = self.alloc_id();
|
|
map.insert(n.id, nid);
|
|
}
|
|
self.paste_nodes(&src_nodes, &map, id, parent, index)
|
|
}
|
|
|
|
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
|
/// remapped by `map`. Interactions and instance references pointing inside the subtree are
|
|
/// remapped too.
|
|
pub fn paste_nodes(
|
|
&mut self,
|
|
nodes: &[Node],
|
|
map: &BTreeMap<NodeId, NodeId>,
|
|
root: NodeId,
|
|
parent: NodeId,
|
|
index: Option<usize>,
|
|
) -> DocResult<NodeId> {
|
|
let p = self.get(parent)?;
|
|
if !p.kind.is_container() {
|
|
return Err(DocError::NotContainer(parent));
|
|
}
|
|
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
|
|
for n in nodes {
|
|
let Some(new_id) = map.get(&n.id) else { continue };
|
|
let mut c = n.clone();
|
|
c.id = *new_id;
|
|
c.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect();
|
|
c.parent = if n.id == root { Some(parent) } else { n.parent.and_then(|p| map.get(&p).copied()) };
|
|
for it in &mut c.interactions {
|
|
for a in &mut it.actions {
|
|
remap_action(a, map);
|
|
}
|
|
}
|
|
if let NodeKind::Instance { component, .. } = &mut c.kind
|
|
&& let Some(m) = map.get(component)
|
|
{
|
|
*component = *m;
|
|
}
|
|
if new_id.0 >= self.next_id {
|
|
self.next_id = new_id.0.saturating_add(1);
|
|
}
|
|
self.nodes.insert(*new_id, Arc::new(c));
|
|
self.dirty.0.insert(*new_id);
|
|
}
|
|
let p = self.get_mut(parent)?;
|
|
let at = index.unwrap_or(p.children.len()).min(p.children.len());
|
|
p.children.insert(at, new_root);
|
|
Ok(new_root)
|
|
}
|
|
|
|
/// Find nodes on `page` (or all pages) whose name contains `query` (case-insensitive).
|
|
pub fn find_by_name(&self, query: &str, page: Option<NodeId>) -> Vec<NodeId> {
|
|
let q = query.to_lowercase();
|
|
let roots: Vec<NodeId> = page.map(|p| vec![p]).unwrap_or_else(|| self.pages.clone());
|
|
roots
|
|
.into_iter()
|
|
.flat_map(|r| self.subtree(r))
|
|
.filter(|i| self.node(*i).is_some_and(|n| !n.is_page() && n.name.to_lowercase().contains(&q)))
|
|
.collect()
|
|
}
|
|
|
|
/// The nearest frame-like ancestor (excluding `id` itself).
|
|
pub fn containing_frame(&self, id: NodeId) -> Option<NodeId> {
|
|
self.ancestors(id).into_iter().find(|a| self.node(*a).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })))
|
|
}
|
|
|
|
/// The top-level frame containing `id` (or `id` if it is one).
|
|
pub fn top_level_frame(&self, id: NodeId) -> Option<NodeId> {
|
|
let mut chain = vec![id];
|
|
chain.extend(self.ancestors(id));
|
|
// chain ends with the page; the element before it is the top-level node.
|
|
let page_pos = chain.iter().position(|c| self.node(*c).is_some_and(Node::is_page))?;
|
|
let top = *chain.get(page_pos.checked_sub(1)?)?;
|
|
self.node(top).filter(|n| n.kind.is_frame_like()).map(|_| top)
|
|
}
|
|
|
|
/// Top-level frames of a page in reading order (rows top to bottom, then left to right).
|
|
pub fn top_frames(&self, page: NodeId) -> Vec<NodeId> {
|
|
let Some(p) = self.node(page) else { return vec![] };
|
|
let mut v: Vec<(NodeId, Rect)> = p
|
|
.children
|
|
.iter()
|
|
.filter(|c| self.node(**c).is_some_and(|n| n.kind.is_frame_like()))
|
|
.filter_map(|c| self.world_bounds(*c).map(|b| (*c, b)))
|
|
.collect();
|
|
v.sort_by(|a, b| a.1.y0.total_cmp(&b.1.y0).then(a.1.x0.total_cmp(&b.1.x0)));
|
|
v.into_iter().map(|(i, _)| i).collect()
|
|
}
|
|
|
|
/// A unique name like "Frame 3" for a new node of a kind.
|
|
pub fn next_name(&self, base: &str) -> String {
|
|
let mut max = 0u64;
|
|
for n in self.nodes() {
|
|
if let Some(rest) = n.name.strip_prefix(base)
|
|
&& let Some(num) = rest.strip_prefix(' ')
|
|
&& let Ok(k) = num.parse::<u64>()
|
|
{
|
|
max = max.max(k);
|
|
}
|
|
}
|
|
format!("{base} {}", max + 1)
|
|
}
|
|
|
|
pub fn style(&self, id: &str) -> Option<&Style> {
|
|
self.styles.iter().find(|s| s.id == id || s.name == id)
|
|
}
|
|
|
|
/// The effective variable modes for a node (walking up its ancestors).
|
|
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
|
let mut out = BTreeMap::new();
|
|
let mut chain = vec![id];
|
|
chain.extend(self.ancestors(id));
|
|
for c in chain.into_iter().rev() {
|
|
if let Some(n) = self.node(c) {
|
|
for (k, v) in &n.var_modes {
|
|
out.insert(k.clone(), v.clone());
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Check structural invariants (used by tests and after loading untrusted files).
|
|
pub fn validate(&self) -> Vec<String> {
|
|
let mut issues = Vec::new();
|
|
for (id, n) in self.nodes.iter() {
|
|
if n.id != *id {
|
|
issues.push(format!("node key {id} has id {}", n.id));
|
|
}
|
|
for c in &n.children {
|
|
match self.node(*c) {
|
|
Some(cn) if cn.parent == Some(*id) => {}
|
|
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
|
None => issues.push(format!("child {c} of {id} missing")),
|
|
}
|
|
}
|
|
if let Some(p) = n.parent
|
|
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
|
|
{
|
|
issues.push(format!("{id} not listed in parent {p}"));
|
|
}
|
|
if n.is_page() != self.pages.contains(id) {
|
|
issues.push(format!("page list mismatch for {id}"));
|
|
}
|
|
}
|
|
issues
|
|
}
|
|
|
|
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
|
pub fn repair(&mut self) {
|
|
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
|
for id in &ids {
|
|
if let Some(n) = self.nodes.get_mut(id) {
|
|
let n = Arc::make_mut(n);
|
|
n.id = *id;
|
|
}
|
|
}
|
|
// Drop dangling children and children claimed by another parent.
|
|
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
|
for id in &ids {
|
|
let Some(n) = self.nodes.get(id) else { continue };
|
|
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
|
if keep != n.children
|
|
&& let Some(n) = self.nodes.get_mut(id)
|
|
{
|
|
Arc::make_mut(n).children = keep;
|
|
}
|
|
}
|
|
// Orphans (parent missing or not listing them) are removed.
|
|
loop {
|
|
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
|
if n.is_page() {
|
|
return None;
|
|
}
|
|
match n.parent {
|
|
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
|
_ => Some(*k),
|
|
}
|
|
});
|
|
let Some(o) = orphan else { break };
|
|
for d in self.subtree(o) {
|
|
self.nodes.remove(&d);
|
|
}
|
|
}
|
|
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()));
|
|
for (k, n) in self.nodes.iter() {
|
|
if n.is_page() && !self.pages.contains(k) {
|
|
self.pages.push(*k);
|
|
}
|
|
}
|
|
if self.pages.is_empty() {
|
|
let p = self.add_page("Page 1");
|
|
self.current_page = Some(p);
|
|
}
|
|
if let Some(max) = self.nodes.keys().max()
|
|
&& self.next_id <= max.0
|
|
{
|
|
self.next_id = max.0.saturating_add(1);
|
|
}
|
|
if self.current_page.is_none_or(|p| !self.pages.contains(&p)) {
|
|
self.current_page = self.pages.first().copied();
|
|
}
|
|
}
|
|
|
|
/// Hit test: the topmost visible, unlocked node under `pt` on `page` for a click.
|
|
/// `deep` selects the innermost leaf (Cmd-click); otherwise top-level children of frames
|
|
/// resolve like Figma's click-through rules (see [`geometry::select_target`]).
|
|
pub fn hit_test(&self, page: NodeId, pt: Point, tolerance: f64) -> Option<NodeId> {
|
|
geometry::hit_leaf(self, page, pt, tolerance)
|
|
}
|
|
}
|
|
|
|
fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
|
|
use proto::Action::*;
|
|
match a {
|
|
Navigate { dest } | ScrollTo { dest } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => {
|
|
if let Some(m) = map.get(dest) {
|
|
*dest = *m;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|