refactor(engine): move lib.rs items to {command_sets,doc_state,error,history,interaction,prefs,sanity,session,session/documents,session/edit,session/execute,session/undo}.rs

This commit is contained in:
aiongg
2026-10-26 22:01:25 +00:00
parent 4f7dd93268
commit ef991b9c8a
13 changed files with 2488 additions and 2418 deletions
+465
View File
@@ -0,0 +1,465 @@
use super::error::{EngineError, Result};
use super::history::{History, HistoryEntry};
use super::interaction::{Interaction, LastTransform, UndoGroup};
use super::*;
/// Per-document editing state.
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Document>,
pub selection: Selection,
pub history: History,
pub path: Option<String>,
/// Increments on every change (also selection and view-only changes); UIs re-render when it
/// moves. Not a dirty flag: see [`DocState::is_dirty`].
pub revision: u64,
/// The document as last saved (or opened). Structural sharing makes "unchanged" a pointer
/// comparison, and undoing back to the saved state counts as clean.
pub(crate) saved_doc: Arc<Document>,
/// The documents that changes that are not edits to save replaced ([`DocState::same_content`]).
unsaved_changes: SameContent,
/// The layer new art goes into (the "current layer" in the Layers panel).
pub active_layer: Option<NodeId>,
/// The rows highlighted in the Layers panel (layers, sublayers, groups or objects, in the order
/// they were clicked): what the panel's Duplicate, Delete, Merge, Options… and similar act on.
/// Panel state, not art selection: not saved, not undoable (`layer.setCurrent`,
/// `layer.highlight`).
pub layer_rows: Vec<NodeId>,
/// The variable highlighted in the Variables panel: what its Delete, Options… and Select
/// Bound Object act on. Panel state, not art selection: not saved, not undoable
/// (`variable.highlight`). A name, so renaming a variable moves the highlight with it.
pub variables_highlight: Option<String>,
/// Isolation mode container.
pub isolation: Option<NodeId>,
/// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape
/// ([`cmd::globaledit`]). Editing state: not saved, not undoable.
pub global_edit: Option<cmd::globaledit::GlobalEdit>,
pub interaction: Option<Interaction>,
/// Edits made while this is open are one undo step (a scrubbed numeric field).
pub undo_group: Option<UndoGroup>,
/// For Object → Transform → Transform Again (⌘D): what the last transformation was. A
/// transform command keeps the `copy`, `typeAreas` and `patterns` it used, and `strokes` and
/// `corners` when it scales; a drag keeps the params of its last preview with the values that
/// preview noted ([`Session::note_journal`]).
pub last_transform: Option<LastTransform>,
/// What Select → Reselect repeats: the last selection command, its params and the objects
/// selected when it ran (a Same command's reference objects).
pub last_selection_cmd: Option<(String, Value, Vec<NodeId>)>,
/// The saved selection last recalled (`select.recall`), which Select → Update Selection
/// changes. It is not saved with the file, and a recall is not an undo step.
pub recalled_selection: RecalledSelection,
/// Process-unique id of this open document (tab indices shift when tabs close).
pub uid: u64,
/// View Opacity Mask (Alt-click the mask thumbnail): the masked object whose mask the canvas
/// shows alone, in greyscale, while that mask is edited (see [`DocState::shown_mask`]).
pub mask_view: Option<NodeId>,
/// View → Show Transparency Grid, per document (saved view state, not undoable).
pub transparency_grid: bool,
/// The format Save writes ([`cmd::fileio::SAVE_FORMATS`]): the one the document was opened
/// from or last saved as.
pub format: &'static str,
/// That format's options as last saved (SVG options for SVG, the Save PDF settings for PDF;
/// empty for native files): Save reuses them and `file.formatOptions` reads them back.
pub save_options: serde_json::Map<String, Value>,
/// The ids of the document's artboards, in order, when `save_options` were stored: the
/// artboards their `artboard`, `artboards` and `range` named by number then
/// ([`cmd::fileio::remembered_save_options`]).
pub save_artboards: Vec<u32>,
/// Opened from an older native file (former name or format version): the title says
/// "[Converted]" and Save asks for a new name instead of overwriting it.
pub converted: bool,
/// The view saved into native files (`Document::last_view`); the UI keeps it current before a
/// save and restores it when the document opens.
pub view: Option<vectorcraft_doc::SavedView>,
/// Restored by Data Recovery: the title says "[Recovered]" and Save asks where to save it
/// (suggesting `path`, the file it was copied from) instead of overwriting that file.
pub recovered: bool,
/// The document's Data Recovery copy, once one was written ([`cmd::recovery`]).
pub recovery: Option<cmd::recovery::RecoveryCopy>,
/// View → Show Print Tiling, per document (view state: not saved, not undoable).
pub print_tiling: bool,
/// The rows open in the Layers panel (view state: not undoable; native files keep it).
pub layers_open: OpenRows,
/// The file this document was read from, and what reading it left out (hidden text, art or
/// layers it could not read): writing the document over that file would lose those for good,
/// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved.
pub imported_from: Option<String>,
pub import_losses: Vec<String>,
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
pub last_perspective: Option<Value>,
/// The folders a confined (automation) caller that opened or made the document may read: the
/// paths the document links to are looked at within them later too, when the Links panel
/// asks about a link outside any caller's scope.
pub opened_by: Option<Arc<file_access::AutomationRoots>>,
}
static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The rows open in the Layers panel: the layers, sublayers and groups that show what they hold.
/// A document opens with the ones it was saved with ([`Document::layers_open`]), else with only
/// its top-level layers open (a `.ai` another app wrote opens with none open).
#[derive(Clone, Debug, Default)]
pub struct OpenRows {
ids: std::collections::HashSet<NodeId>,
/// Counts the changes, so views can keep what they work out from the open rows.
generation: u64,
}
impl OpenRows {
/// The rows `saved` in a file, else `doc`'s top-level layers.
fn new(doc: &Document, saved: Option<Vec<NodeId>>) -> Self {
let ids = match saved {
Some(ids) => ids.into_iter().collect(),
None => doc.layers.iter().map(|l| l.id).collect(),
};
Self { ids, generation: 0 }
}
pub fn contains(&self, id: NodeId) -> bool {
self.ids.contains(&id)
}
/// Open or close row `id`.
pub fn set(&mut self, id: NodeId, open: bool) {
let changed = if open { self.ids.insert(id) } else { self.ids.remove(&id) };
self.generation += u64::from(changed);
}
/// Changes so far: the same number means the same open rows.
pub fn generation(&self) -> u64 {
self.generation
}
/// What a native file keeps of the open rows: those of `doc` that hold others, in id order;
/// `None` when they are the default (only the top-level layers).
pub fn saved(&self, doc: &Document) -> Option<Vec<NodeId>> {
let mut ids = vec![];
doc.walk(|n| {
if n.children().is_some() && self.ids.contains(&n.id) {
ids.push(n.id);
}
});
ids.sort_unstable();
let mut layers: Vec<NodeId> = doc.layers.iter().map(|l| l.id).collect();
layers.sort_unstable();
(ids != layers).then_some(ids)
}
}
/// The saved selection last recalled, which Select → Update Selection changes. The Select commands
/// give each saved selection an id for the session and keep the ids for every version of the
/// document's saved selections they make or read, so the recalled one is found again after Edit
/// Selection…, Undo and Redo. A version that nothing holds any more (neither the document nor its
/// undo history) is dropped the next time a version is added.
#[derive(Clone, Debug, Default)]
pub struct RecalledSelection {
/// The recalled saved selection's id.
id: Option<u64>,
/// Each version of the saved selections (identified by its allocation), with the id of each saved
/// selection in it.
versions: Vec<(std::sync::Weak<Vec<vectorcraft_doc::SavedSelection>>, Vec<u64>)>,
/// The last id given.
last: u64,
}
impl RecalledSelection {
/// The ids of `list`'s saved selections; a version not met before gets new ones.
pub(crate) fn ids(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>) -> Vec<u64> {
let version = vectorcraft_doc::Shared::downgrade(list);
if let Some((_, ids)) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version)) {
return ids.clone();
}
let ids: Vec<u64> = (0..list.len()).map(|_| self.new_id()).collect();
self.remember(list, ids.clone());
ids
}
/// An id no saved selection has had.
pub(crate) fn new_id(&mut self) -> u64 {
self.last += 1;
self.last
}
/// Keep `ids` as those of `list`'s saved selections, dropping versions nothing holds any more.
pub(crate) fn remember(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>, ids: Vec<u64>) {
let version = vectorcraft_doc::Shared::downgrade(list);
self.versions.retain(|(v, _)| v.strong_count() > 0 && !v.ptr_eq(&version));
self.versions.push((version, ids));
}
/// Recall the saved selection at `index` in `list`.
pub(crate) fn recall(&mut self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>, index: usize) {
self.id = self.ids(list).get(index).copied();
}
/// Where the recalled saved selection is in `list`, if `list` holds it.
pub fn index_in(&self, list: &vectorcraft_doc::Shared<Vec<vectorcraft_doc::SavedSelection>>) -> Option<usize> {
let id = self.id?;
let version = vectorcraft_doc::Shared::downgrade(list);
let (_, ids) = self.versions.iter().find(|(v, _)| v.ptr_eq(&version))?;
ids.iter().position(|&x| x == id).filter(|&i| i < list.len())
}
}
/// The documents that changes that are not edits to save (Puppet Warp pins dropped, Edit Text ended,
/// type laid out again after a font scan) replaced, each counted as having the content of the
/// document it was replaced by ([`DocState::change_unsaved`]). A document that nothing holds any
/// more is dropped the next time one is added.
#[derive(Clone, Debug, Default)]
struct SameContent {
/// The document the last such change made.
now: std::sync::Weak<Document>,
/// The documents with its content that such changes replaced, oldest first.
before: Vec<std::sync::Weak<Document>>,
}
impl SameContent {
/// Note that `now` replaced `before` without a change to its content.
fn replaced(&mut self, before: std::sync::Weak<Document>, now: &Arc<Document>) {
if !self.now.ptr_eq(&before) {
self.before.clear();
}
self.before.retain(|d| d.strong_count() > 0);
if before.strong_count() > 0 {
self.before.push(before);
}
self.now = Arc::downgrade(now);
}
/// Whether `doc` is one of the documents with the content of `now`.
fn holds(&self, now: &Arc<Document>, doc: &Arc<Document>) -> bool {
std::ptr::eq(self.now.as_ptr(), Arc::as_ptr(now)) && self.before.iter().any(|d| std::ptr::eq(d.as_ptr(), Arc::as_ptr(doc)))
}
}
impl DocState {
pub fn new(mut doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
// Saved view state lives here while the document is open (saves write it back).
let view = doc.last_view.take();
let transparency_grid = std::mem::take(&mut doc.transparency_grid);
let saved_open = doc.layers_open.take();
let layers_open = OpenRows::new(&doc, saved_open);
let doc = Arc::new(doc);
Self {
saved_doc: doc.clone(),
unsaved_changes: SameContent::default(),
doc,
selection: Selection::default(),
history: History { limit: 500, ..Default::default() },
path,
revision: 1,
active_layer,
layer_rows: vec![],
variables_highlight: None,
isolation: None,
global_edit: None,
interaction: None,
undo_group: None,
last_transform: None,
last_selection_cmd: None,
recalled_selection: RecalledSelection::default(),
uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
mask_view: None,
transparency_grid,
format: "vectorcraft",
save_options: Default::default(),
save_artboards: vec![],
converted: false,
view,
recovered: false,
recovery: None,
print_tiling: false,
layers_open,
imported_from: None,
import_losses: vec![],
last_perspective: None,
opened_by: file_access::current(),
}
}
/// Unsaved changes: the document differs from the saved one (selection changes and changes that
/// are not edits to save don't count, [`DocState::same_content`]).
pub fn is_dirty(&self) -> bool {
!self.same_content(&self.saved_doc)
}
/// Record the current document as saved.
pub fn mark_saved(&mut self) {
self.saved_doc = self.doc.clone();
}
/// Record `snapshot` (the document as a background save took it) as saved: edits made since
/// keep the document modified.
pub fn mark_saved_as(&mut self, snapshot: &Arc<Document>) {
self.saved_doc = snapshot.clone();
}
/// Whether `doc` counts as having the content of the document now: it is the document, or one
/// that changes that are not edits to save replaced since ([`DocState::change_unsaved`]).
pub fn same_content(&self, doc: &Arc<Document>) -> bool {
Arc::ptr_eq(&self.doc, doc) || self.unsaved_changes.holds(&self.doc, doc)
}
/// Make a change that is not an edit to save with `f`, without an undo step: editing state that
/// is never saved, or type laid out again after a font scan (which can also refit the frame of
/// Auto Size area type). A saved document stays saved, and a save or recovery copy taken of the
/// document before counts as having its content ([`DocState::same_content`]).
pub(crate) fn change_unsaved(&mut self, f: impl FnOnce(&mut Arc<Document>)) {
let saved = !self.is_dirty();
let before = Arc::downgrade(&self.doc);
f(&mut self.doc);
if !std::ptr::eq(before.as_ptr(), Arc::as_ptr(&self.doc)) {
self.unsaved_changes.replaced(before, &self.doc);
}
if saved {
self.mark_saved();
}
}
/// Keep what interaction `it` (taken from this document) changed, as one undo step.
pub(crate) fn keep_interaction(&mut self, it: Interaction) {
if !Arc::ptr_eq(&it.doc, &self.doc) {
let selection = match self.history.undo.last() {
Some(l) if l.selection == it.selection => l.selection.clone(),
_ => it.selection,
};
self.push_undo(it.label, it.doc, selection);
self.revision += 1;
}
}
/// Record an undo step: `doc` and `selection` as they were before an edit (the document now is
/// the one after it). In an undo group only the group's first edit records one: the edits
/// after it extend that step.
pub(crate) fn push_undo(&mut self, mut label: String, doc: Arc<Document>, selection: Arc<Selection>) {
if let Some(g) = &mut self.undo_group {
let recorded = |first: &Arc<Document>| self.history.undo.last().is_some_and(|l| Arc::ptr_eq(&l.doc, first));
if g.first.as_ref().is_some_and(recorded) {
self.history.recost_last(&self.doc);
self.history.trim();
return;
}
g.first = Some(doc.clone());
if let Some(l) = &g.label {
label = l.clone();
}
}
let e = HistoryEntry::kept(label, doc, selection, &self.doc);
self.history.undo.push(e);
self.history.redo.clear();
self.history.trim();
}
/// End the interaction in progress, undoing what it changed.
pub(crate) fn undo_interaction(&mut self) {
if let Some(it) = self.interaction.take() {
self.doc = it.doc;
self.selection = Arc::unwrap_or_clone(it.selection);
self.sync_new_art();
cmd::globaledit::follow_history(self);
self.active_layer = it.active_layer;
self.layer_rows = it.layer_rows;
self.variables_highlight = it.variables_highlight;
self.isolation = it.isolation;
self.revision += 1;
}
}
/// Count the document as modified, as if never saved (a document restored by Data Recovery).
pub fn mark_unsaved(&mut self) {
self.saved_doc = Arc::new(Document::new(1.0, 1.0));
}
/// Deselect the art on a locked artboard, except new art ([`Document::new_art`]). New art
/// becomes the objects of layers on a locked artboard that hold selected objects (a group for
/// its selected members) and that an edit made (those not in `before`, the document before
/// it), or that held objects selected in `was` that were new art (`old`, the new art of that
/// selection), or were inside new art in `before` (the members of an ungrouped group). New art
/// that a selection leaves out, or that leaves the locked artboards, is protected from then
/// on. The selection and the document get the new art; the document's changes in place, so a
/// selection change doesn't replace the document.
pub(crate) fn protect_locked_artboards(&mut self, was: &[NodeId], old: &std::collections::HashSet<NodeId>, before: Option<&Document>) {
let new_art: std::collections::HashSet<NodeId> = {
let (doc, locks) = (&self.doc, self.doc.locked_artboards());
if locks.is_empty() {
Default::default()
} else {
// `was` and `old` name the objects of the document they were selected in.
let then = before.unwrap_or(doc);
let was: std::collections::HashSet<NodeId> =
was.iter().filter_map(|o| then.layer_object(*o)).map(|n| n.id).filter(|o| old.contains(o)).collect();
let selected: Vec<(NodeId, &vectorcraft_doc::Node)> =
self.selection.objects.iter().filter_map(|o| doc.layer_object(*o).map(|n| (*o, n))).collect();
// One walk of the document before the edit finds where the selected objects were;
// the objects the edit made aren't in it.
let paths = before.filter(|_| !selected.is_empty()).map(|b| b.index_paths(selected.iter().flat_map(|(o, n)| [*o, n.id])));
let new = |o: NodeId, object: NodeId| {
was.contains(&object)
|| before.zip(paths.as_ref()).is_some_and(|(b, p)| {
!p.contains_key(&object) || p.get(&o).and_then(|p| b.layer_object_at(p)).is_some_and(|n| was.contains(&n.id))
})
};
selected.into_iter().filter(|(o, n)| new(*o, n.id) && locks.overlaps(n)).map(|(_, n)| n.id).collect()
}
};
if new_art != *self.selection.new_art {
self.selection.new_art = Arc::new(new_art);
}
self.sync_new_art();
self.selection.deselect_on_locked_artboards(&self.doc);
}
/// Give the document the selection's new art ([`Selection::new_art`]), less the objects the
/// selection no longer holds: after Undo, Redo or a failed batch brings back a document and
/// a selection, and after a command that changed the selection directly.
pub(crate) fn sync_new_art(&mut self) {
if !self.selection.new_art.is_empty() {
let held: std::collections::HashSet<NodeId> =
self.selection.objects.iter().filter_map(|o| self.doc.layer_object(*o)).map(|n| n.id).collect();
if !self.selection.new_art.is_subset(&held) {
self.selection.new_art = Arc::new(self.selection.new_art.intersection(&held).copied().collect());
}
}
let doc = self.doc.new_art.get();
if !Arc::ptr_eq(&doc, &self.selection.new_art) && *doc != *self.selection.new_art {
self.doc.new_art.set(self.selection.new_art.clone());
}
}
pub fn title(&self) -> String {
let name = self
.path
.as_deref()
.and_then(|p| std::path::Path::new(p).file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.doc.title.clone());
if self.converted {
format!("{} [Converted]", cmd::fileio::file_stem(&name))
} else if self.recovered {
format!("{} [Recovered]", cmd::fileio::file_stem(&name))
} else {
name
}
}
/// Where new art is inserted: the isolation container, else the active layer.
/// The current layer, if the remembered id still names a layer (ids are reused after undo).
pub fn current_layer(&self) -> Option<NodeId> {
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()))
}
pub fn insertion_parent(&self) -> Option<NodeId> {
// Ids are reused after undo (the id counter is part of the document), so a remembered
// layer or isolated group must still be one: a pasted group must never land in a path.
if let Some(i) = self.isolation
&& self.doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
{
return Some(i);
}
// A single object isolated: new art goes beside it, in its group or layer.
if let Some(p) = self.isolation.filter(|i| self.doc.node(*i).is_some_and(|n| !n.is_container())).and_then(|i| self.doc.parent_of(i))
&& self.doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }))
{
return Some(p);
}
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}
/// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error
/// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away.
pub fn target_parent(&self) -> Result<Option<NodeId>> {
match self.insertion_parent() {
Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())),
parent => Ok(parent),
}
}
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
/// remembered row must still be in the document).
pub fn highlighted_rows(&self) -> Vec<NodeId> {
self.layer_rows.iter().copied().filter(|id| self.doc.node(*id).is_some()).collect()
}
/// The object whose opacity mask View Opacity Mask shows ([`DocState::mask_view`]): only while
/// its mask is being edited, so leaving editing by any route (undo, deleting the object) ends it.
pub fn shown_mask(&self) -> Option<NodeId> {
self.mask_view.filter(|id| self.doc.mask_edit.is_some_and(|m| m.object == *id))
}
}