//! The VectorCraft engine façade. //! //! Every user-visible action is a command with a stable id (`object.group`, `select.same.fillColor`, //! `shape.rectangle`…) and JSON parameters. The egui UI, the CLI, the control channel and the MCP //! server all go through [`Session::execute`]. Tools (pointer gestures) are hosted here too and //! reduce to commands, so every gesture is journaled and replayable. #![forbid(unsafe_code)] pub mod cmd; pub mod guard; pub mod inspect; mod tooling; pub mod units; use std::sync::Arc; use serde_json::Value; use vectorcraft_color::{Color, GradientPaint, Paint}; use vectorcraft_doc::{Document, NodeId, NodeKind, Selection}; use vectorcraft_geom::Affine; use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::EyedropperOptions; pub use cmd::clipboard::Clipboard; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use tooling::{UiRequest, ViewInfo}; pub use vectorcraft_doc as doc; pub use vectorcraft_tools as tools; #[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 active document")] NoDocument, #[error("no such object {0}")] NoNode(NodeId), #[error("{0}")] Other(String), /// A bug: the command panicked. The document was rolled back to its state before the command. #[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")] Internal { cmd: String, msg: String }, } impl From for EngineError { fn from(e: vectorcraft_doc::DocError) -> Self { EngineError::Other(e.to_string()) } } pub type Result = std::result::Result; /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { pub label: String, pub doc: Arc, pub selection: Selection, } #[derive(Clone, Debug, Default)] pub struct History { pub undo: Vec, pub redo: Vec, /// Maximum undo depth (Illustrator has no hard limit; snapshots are cheap here). pub limit: usize, } /// An in-progress drag: the snapshot the previews are applied to. #[derive(Clone, Debug)] pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Selection, pub preview: Option<(String, Value)>, /// Per-document state restored on cancel (current layer, isolation). pub active_layer: Option, pub isolation: Option, /// The perspective transform the previews make (`perspective.transform` params): Transform /// Again repeats it once the drag is committed. pub perspective_again: Option, } /// Per-document editing state. #[derive(Clone, Debug)] pub struct DocState { pub doc: Arc, pub selection: Selection, pub history: History, pub path: Option, /// 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. saved_doc: Arc, /// The layer new art goes into (the "current layer" in the Layers panel). pub active_layer: Option, /// Isolation mode container. pub isolation: Option, pub interaction: Option, /// For Object → Transform → Transform Again (⌘D). pub last_transform: Option<(Affine, bool)>, /// Selection saved by Select → Reselect. pub last_selection_cmd: Option<(String, Value)>, /// 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, /// View → Show Transparency Grid, per document (view state: not saved, 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, /// 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, /// 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, /// View → Show Print Tiling, per document (view state: not saved, not undoable). pub print_tiling: bool, /// 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, } static NEXT_DOC_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); impl DocState { pub fn new(mut doc: Document, path: Option) -> Self { let active_layer = doc.default_layer(); // The saved view lives here while the document is open (saves write it back). let view = doc.last_view.take(); let doc = Arc::new(doc); Self { saved_doc: doc.clone(), doc, selection: Selection::default(), history: History { limit: 500, ..Default::default() }, path, revision: 1, active_layer, isolation: None, interaction: None, last_transform: None, last_selection_cmd: None, uid: NEXT_DOC_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), mask_view: None, transparency_grid: false, format: "vectorcraft", save_options: Default::default(), converted: false, view, recovered: false, recovery: None, print_tiling: false, last_perspective: None, } } /// Unsaved changes: the document differs from the saved one (selection changes don't count). pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(&self.doc, &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) { self.saved_doc = snapshot.clone(); } /// 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) { self.history.undo.push(HistoryEntry { label: it.label, doc: it.doc, selection: it.selection }); self.history.redo.clear(); self.revision += 1; } } /// 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 = it.selection; self.active_layer = it.active_layer; 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)); } 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 { self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer())) } pub fn insertion_parent(&self) -> Option { // 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); } self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer() && !n.locked)).or_else(|| self.doc.default_layer()) } /// 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 { self.mask_view.filter(|id| self.doc.mask_edit.is_some_and(|m| m.object == *id)) } } /// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas. pub const MAX_COORD: f64 = 4.0e6; /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`); an action leaves them out /// ([`Session::journal_for_action`]). const REPLAY_ONLY: [(&str, &str); 3] = [("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified")]; /// `p`, the params of command `id`, without its [`REPLAY_ONLY`] values (a batch: its steps'). fn strip_replay_only(id: &str, p: &mut Value) { let strip = |id: &str, p: &mut Value| { if let Value::Object(m) = p { m.retain(|k, _| !REPLAY_ONLY.iter().any(|&(c, key)| c == id && key == k)); } }; strip(id, p); // Batches don't nest: one level of steps. if id == "command.batch" && let Some(Value::Array(steps)) = p.get_mut("commands") { for step in steps { let id = step.get("command").and_then(Value::as_str).unwrap_or_default().to_string(); if let Some(p) = step.get_mut("params") { strip(&id, p); } } } } /// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must /// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. fn doc_sane(d: &Document, sel: &Selection) -> bool { let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); d.artboards.iter().all(|a| ok(a.rect)) && sel.objects.iter().all(|id| d.node(*id).and_then(|n| n.geometric_bounds()).is_none_or(ok)) } /// Where new art goes (Illustrator's drawing modes, Shift+D cycles). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum DrawMode { #[default] Normal, Behind, Inside, } /// Application preferences (Edit → Preferences). Every field is reachable through `prefs.get` / /// `prefs.set {key, value}` (camelCase keys, validated against [`cmd::prefscmds::PREF_SPECS`]). #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Prefs { // General /// Arrow-key nudge distance (General → Keyboard Increment), points. pub keyboard_increment: f64, pub constrain_angle: f64, pub corner_radius: f64, pub disable_auto_add_delete: bool, pub use_precise_cursors: bool, pub show_tool_tips: bool, pub anti_aliased_artwork: bool, pub select_same_tint_percent: bool, pub show_home_screen: bool, pub use_preview_bounds: bool, pub display_print_size: bool, pub double_click_to_isolate: bool, pub transform_pattern_tiles: bool, pub scale_corners: bool, /// Scale Strokes & Effects. pub scale_strokes: bool, pub zoom_with_mouse_wheel: bool, /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). pub paste_offset: f64, // Selection & Anchor Display pub selection_tolerance: f64, pub object_selection_by_path_only: bool, pub snap_to_point_tolerance: f64, pub ctrl_click_selects_behind: bool, pub zoom_to_selection: bool, pub move_locked_with_artboard: bool, pub anchor_size: u32, pub handle_style: String, pub highlight_anchors_on_hover: bool, pub show_handles_multiple_anchors: bool, pub hide_corner_widget_above: f64, pub pen_rubber_band: bool, pub curvature_rubber_band: bool, // Type pub type_size_increment: f64, pub tracking_increment: f64, pub baseline_shift_increment: f64, pub show_east_asian_options: bool, pub show_indic_options: bool, pub type_selection_by_path_only: bool, pub font_names_in_english: bool, pub auto_size_area_type: bool, pub font_preview: bool, pub font_preview_size: String, pub recent_fonts_count: u32, pub missing_glyph_protection: bool, pub highlight_alternate_glyphs: bool, pub placeholder_text: bool, // Units pub units_general: String, pub units_stroke: String, pub units_type: String, pub units_asian_type: String, pub numbers_without_units_are_points: bool, pub identify_objects_by: String, // Guides & Grid pub guide_color: String, pub guide_style: String, pub grid_color: String, pub grid_style: String, pub gridline_every: f64, pub grid_subdivisions: u32, pub grids_in_back: bool, pub show_pixel_grid: bool, // Smart Guides pub smart_guide_color: String, pub alignment_guides: bool, pub object_highlighting: bool, pub transform_tools_guides: bool, pub construction_guides: bool, pub construction_angles: String, pub anchor_path_labels: bool, pub measurement_labels: bool, pub spacing_guides: bool, pub snapping_tolerance: f64, // Slices pub show_slice_numbers: bool, pub slice_line_color: String, // Hyphenation pub hyphenation_language: String, pub hyphenation_exceptions: String, // Performance & Storage (Plug-ins & Scratch Disks) pub plugins_folder: String, pub scratch_primary: String, pub scratch_secondary: String, // User Interface pub ui_brightness: String, pub canvas_color: String, pub auto_collapse_icon_panels: bool, pub open_documents_as_tabs: bool, pub large_tabs: bool, pub ui_scaling: f64, pub scale_cursor_with_ui: bool, // Performance pub gpu_performance: bool, pub animated_zoom: bool, pub history_states: u32, pub real_time_drawing: bool, /// Rasterizer worker threads; -1 = automatic. pub render_threads: i32, // File Handling pub background_save: bool, pub background_export: bool, pub autosave_recovery: bool, pub autosave_interval: u32, pub recovery_folder: String, pub recovery_off_for_complex: bool, pub recent_files_count: u32, pub low_res_proxy_eps: bool, pub anti_aliased_bitmaps: bool, pub update_links: String, // Clipboard Handling pub copy_as_svg: bool, pub copy_as_pdf: bool, pub copy_aicb: bool, pub aicb_mode: String, pub paste_text_formatting: String, // Appearance of Black pub black_on_screen: String, pub black_output: String, // Devices pub touch_workspace: bool, pub touch_gestures: bool, // Graphic Styles panel /// Override Character Color: a graphic style applied to type replaces its characters' fill /// and stroke with the style's fills and strokes. pub override_char_color: bool, // Pattern editing /// Object → Pattern → Tile Edge Color (`#rrggbb`): the tile edge and swatch bounds in pattern /// editing mode. pub pattern_tile_edge_color: String, /// Eyedropper Options (`eyedropper.setOptions`; a preference group, see /// [`cmd::prefscmds::PREF_GROUPS`]). pub eyedropper: EyedropperOptions, /// Edit → Transparency Flattener Presets: the user's presets (the built-in ones aren't stored). /// Not a `prefs.set` key or group (resetting the preferences keeps them): `flattener.presets.*` /// edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub flattener_presets: Vec, /// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a /// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row. #[serde(skip_serializing_if = "Vec::is_empty")] pub width_profiles: Vec, /// General → Use Japanese Crop Marks: the style of Create Trim Marks and Effect → Crop Marks. pub japanese_crop_marks: bool, /// Appearance panel → New Art Has Basic Appearance (on): new art takes one fill and stroke; /// off, the whole appearance of the last selection (`appearance.setNewArtBasic`). pub new_art_basic: bool, /// The Color Themes panel's saved themes (`colorTheme.*`): a local library, not a Preferences /// dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row and resetting keeps it. #[serde(skip_serializing_if = "Vec::is_empty")] pub color_themes: Vec, /// New Document → Saved: the user's document presets (`file.newPresets.save`). A local /// library, not a Preferences dialog field: resetting the preferences keeps it. #[serde(skip_serializing_if = "Vec::is_empty")] pub new_doc_presets: Vec, /// New Document → Recent: the settings of the last documents made (newest first). #[serde(skip_serializing_if = "Vec::is_empty")] pub recent_new_docs: Vec, /// Edit → PDF Presets: the user's presets (the built-in ones aren't stored). A local library, /// not a Preferences dialog field: resetting the preferences keeps it; `pdf.preset.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub pdf_presets: Vec, // File Handling (continued) /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). pub templates_folder: String, /// Open older native files as " [Converted]" so Save asks for a new name. pub append_converted: bool, /// File Handling → Use Compression: native saves are gzip-compressed (`document.save /// {compress}` overrides it). pub use_compression: bool, /// Save for Web: the user's presets (the built-in ones aren't stored). A local library, not a /// Preferences dialog field: resetting the preferences keeps it; `webExport.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub web_export_presets: Vec, /// Save for Web: the settings the dialog opens on (`webExport.settings`); none until set. #[serde(skip_serializing_if = "Option::is_none")] pub web_export_settings: Option, /// Edit → Print Presets: the user's presets ([Default] isn't stored). A local library, not a /// Preferences dialog field: resetting the preferences keeps it; `print.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub print_presets: Vec, /// Constrain Width and Height Proportions: the link between W and H in the Transform panel, /// the Properties panel and the Control bar (their size fields pass `proportional` to /// `object.setBounds`). pub constrain_proportions: bool, /// The tools' persistent options by store (a tool id, or a store a family shares: `liquify` /// holds the Liquify tools' Global Brush Dimensions), see [`vectorcraft_tools::settings`]: /// kept across tool switches and saved with the preferences. Not a Preferences dialog field: /// resetting the preferences keeps them; `tool.setOption` edits them. #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] pub tool_settings: std::collections::BTreeMap>, /// View → Perspective Grid presets: the user's (the built-in ones aren't stored). A local /// library, not a Preferences dialog field: resetting the preferences keeps it; /// `perspective.presets.*` edit it. #[serde(skip_serializing_if = "Vec::is_empty")] pub perspective_presets: Vec, } impl Default for Prefs { fn default() -> Self { let s = |v: &str| v.to_string(); Self { keyboard_increment: 1.0, constrain_angle: 0.0, corner_radius: 12.0, disable_auto_add_delete: false, use_precise_cursors: false, show_tool_tips: true, anti_aliased_artwork: true, select_same_tint_percent: false, show_home_screen: true, use_preview_bounds: false, display_print_size: false, double_click_to_isolate: true, transform_pattern_tiles: false, scale_corners: false, scale_strokes: false, zoom_with_mouse_wheel: false, paste_offset: 10.0, selection_tolerance: 3.0, object_selection_by_path_only: false, snap_to_point_tolerance: 2.0, ctrl_click_selects_behind: true, zoom_to_selection: true, move_locked_with_artboard: false, anchor_size: 3, handle_style: s("solid"), highlight_anchors_on_hover: true, show_handles_multiple_anchors: true, hide_corner_widget_above: 177.0, pen_rubber_band: true, curvature_rubber_band: true, type_size_increment: 2.0, tracking_increment: 20.0, baseline_shift_increment: 2.0, show_east_asian_options: false, show_indic_options: false, type_selection_by_path_only: false, font_names_in_english: true, auto_size_area_type: false, font_preview: true, font_preview_size: s("medium"), recent_fonts_count: 10, missing_glyph_protection: true, highlight_alternate_glyphs: true, placeholder_text: true, units_general: s("points"), units_stroke: s("points"), units_type: s("points"), units_asian_type: s("points"), numbers_without_units_are_points: true, identify_objects_by: s("objectName"), guide_color: s("#4affff"), guide_style: s("lines"), grid_color: s("#c8c8c8"), grid_style: s("lines"), gridline_every: 72.0, grid_subdivisions: 8, grids_in_back: true, show_pixel_grid: true, smart_guide_color: s("#ff4af0"), alignment_guides: true, object_highlighting: true, transform_tools_guides: true, construction_guides: true, construction_angles: s("90° & 45° Angles"), anchor_path_labels: true, measurement_labels: true, spacing_guides: true, snapping_tolerance: 4.0, show_slice_numbers: true, slice_line_color: s("#ff3f3f"), hyphenation_language: s("English: USA"), hyphenation_exceptions: String::new(), plugins_folder: String::new(), scratch_primary: s("Startup"), scratch_secondary: s("None"), ui_brightness: s("mediumDark"), canvas_color: s("matchUi"), auto_collapse_icon_panels: false, open_documents_as_tabs: true, large_tabs: false, ui_scaling: 1.0, scale_cursor_with_ui: false, gpu_performance: true, animated_zoom: true, history_states: 500, real_time_drawing: true, render_threads: -1, background_save: true, background_export: true, autosave_recovery: true, autosave_interval: 2, recovery_folder: String::new(), recovery_off_for_complex: false, recent_files_count: 20, low_res_proxy_eps: false, anti_aliased_bitmaps: false, update_links: s("askWhenModified"), copy_as_svg: true, copy_as_pdf: false, copy_aicb: false, aicb_mode: s("preserveAppearance"), paste_text_formatting: s("keep"), black_on_screen: s("accurate"), black_output: s("accurate"), touch_workspace: true, touch_gestures: true, override_char_color: true, pattern_tile_edge_color: { let [r, g, b] = vectorcraft_doc::LAYER_COLORS[0].1; Color::rgb8(r, g, b).to_hex() }, eyedropper: Default::default(), flattener_presets: vec![], width_profiles: vec![], japanese_crop_marks: false, new_art_basic: true, color_themes: vec![], new_doc_presets: vec![], recent_new_docs: vec![], pdf_presets: vec![], templates_folder: String::new(), append_converted: true, use_compression: false, web_export_presets: vec![], web_export_settings: None, print_presets: vec![], constrain_proportions: false, tool_settings: Default::default(), perspective_presets: vec![], } } } pub struct Session { docs: Vec, active: Option, pub prefs: Prefs, /// Fill/stroke for new art (the toolbar proxy). pub paint: PaintDefaults, /// Which proxy is in front (true = Fill, false = Stroke) — the X key toggles. pub fill_active: bool, /// Internal clipboard: the copied objects and the document resources they use. pub clipboard: Clipboard, /// Executed commands (for actions and debugging). pub journal: Vec<(String, Value)>, pub(crate) tool: Box, pub(crate) last_view: ViewInfo, depth: u32, /// Set when the active tool panicked (see [`guard`]); reported by the next tool event. tool_panic: Option, /// Draw Normal / Behind / Inside (toolbar drawing modes). pub draw_mode: DrawMode, /// The path new art is drawn inside (Draw Inside). pub draw_inside: Option, untitled_counter: u32, /// Session-level state of the menu commands (saved selections, guide lock). pub(crate) menu: cmd::menucmds::MenuState, /// The selected gradient stop (`gradient.selectStop`) and whose gradient it was selected on, /// shared by the Gradient tool's annotator, the Gradient and Color panels and agents: read it /// with [`Session::selected_stop`]. pub(crate) gradient_stop: Option<(usize, cmd::gradient::StopOwner)>, /// The Appearance panel's active fill/stroke row (`appearance.setActiveItem`); not saved. Read /// it through [`Session::appearance_item`], which drops it once the selection changes. pub(crate) active_appearance_item: Option, /// The last solid colour applied (the toolbar's Color button; `,` applies it again). pub last_solid: Color, /// The last gradient applied (the toolbar's Gradient button; `.` applies it again). pub last_gradient: GradientPaint, /// Recently applied solid colours, newest first (the Recent Colors rows), fed by every paint /// command whichever frontend runs it. pub recent_colors: Vec, /// A paint applied by a live preview: remembered when the interaction commits. pub(crate) pending_paint: Option, /// User Defined and loaded swatch libraries (Window → Swatch Libraries); not saved. pub swatch_libraries: cmd::swatchlib::Libraries, /// The selected freeform gradient point (`paint.freeform.selectPoint`) and whose gradient it /// was selected on: read it with [`Session::selected_freeform_point`]. pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>, /// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved. pub style_libraries: cmd::stylelib::Libraries, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, /// Parameters the running top-level command resolved from the preferences or the clock, added /// to its journal entry so a replay does the same ([`Session::note_journal`]). journal_note: serde_json::Map, /// The depth of the command whose values [`Session::note_journal`] keeps: 1 (the top-level /// command), or a step's while a batch runs it ([`Session::execute_step`]). note_depth: u32, /// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an /// error brings back; `None` otherwise. pub(crate) batch_stash: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, /// While the active tool's actions run: their commands aren't "a command from outside the /// tool" ([`Session::after_command`]). pub(crate) in_tool_actions: bool, /// Envelope Options with no envelope selected: the options and fidelity new envelopes get /// (`None`: the reference app's defaults, fidelity 50); not saved. pub(crate) envelope_defaults: Option<(vectorcraft_doc::live::EnvelopeOptions, f64)>, } impl Default for Session { fn default() -> Self { Self::new() } } impl Session { pub fn new() -> Self { Self { docs: vec![], active: None, prefs: Prefs::default(), paint: PaintDefaults::default(), fill_active: true, clipboard: Clipboard::default(), journal: vec![], tool: vectorcraft_tools::create("selection"), last_view: ViewInfo::default(), depth: 0, tool_panic: None, draw_mode: DrawMode::Normal, draw_inside: None, untitled_counter: 0, menu: Default::default(), gradient_stop: None, active_appearance_item: None, last_solid: Color::WHITE, last_gradient: GradientPaint::new(Default::default()), recent_colors: vec![], pending_paint: None, swatch_libraries: Default::default(), freeform_point: None, style_libraries: Default::default(), recent_urls: vec![], journal_note: Default::default(), note_depth: 1, batch_stash: None, recovery: Default::default(), in_tool_actions: false, envelope_defaults: None, } } pub fn documents(&self) -> &[DocState] { &self.docs } /// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up). pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> { self.docs.iter_mut().find(|d| d.uid == uid) } pub fn active_index(&self) -> Option { self.active } pub fn active(&self) -> Option<&DocState> { self.active.and_then(|i| self.docs.get(i)) } pub fn active_mut(&mut self) -> Option<&mut DocState> { self.active.and_then(|i| self.docs.get_mut(i)) } pub fn doc(&self) -> Result<&DocState> { self.active().ok_or(EngineError::NoDocument) } pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } /// Finish the tool's pending work in the current document and give it fresh state, so nothing /// (e.g. uncommitted typing) leaks into the document we are switching to. fn reset_tool_for_doc_switch(&mut self) { if self.active.is_none() { return; } let view = self.last_view; let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); let _ = self.apply_actions(acts); // A batch leaves its interaction open in the document it leaves, to keep or roll back with // the rest of the batch; anything else in progress (a drag) is cancelled. if self.batch_stash.is_none() { let _ = self.cancel_interaction(); } self.keep_tool_settings(); self.tool = self.make_tool(self.tool.id()); } pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { if self.active != Some(index) { self.reset_tool_for_doc_switch(); } self.active = Some(index); true } else { false } } /// The name [`Session::next_untitled`] gives next (New Document's Name field). pub fn peek_untitled(&self) -> String { format!("Untitled-{}", self.untitled_counter + 1) } pub fn next_untitled(&mut self) -> String { self.untitled_counter += 1; format!("Untitled-{}", self.untitled_counter) } /// Text layout bounds are a cache (not saved): compute them for a document just read, or /// selection boxes and hit testing would use the rough estimate until each text is edited. fn refresh_text_bounds(doc: &mut Document) { let mut texts = vec![]; doc.walk(|n| { if matches!(n.kind, NodeKind::Text(_)) { texts.push(n.id); } }); for id in texts { if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) && t.cached_bounds.is_none() { cmd::typecmd::refresh_bounds(t); } } } /// Add a document and make it active. pub fn add_document(&mut self, mut doc: Document, path: Option) -> usize { Self::refresh_text_bounds(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); st.history.limit = self.prefs.history_states as usize; self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); i } /// Replace the document in tab `index` (File → Revert): new content, cleared history and /// selection, saved state. The tab keeps its place, path, format and view. pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool { if index >= self.docs.len() { return false; } Self::refresh_text_bounds(&mut doc); if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); } let old = &self.docs[index]; let mut st = DocState::new(doc, old.path.clone()); st.history.limit = old.history.limit; // Same open document (caches keyed by uid stay valid); a new revision redraws it. st.uid = old.uid; st.revision = old.revision + 1; st.format = old.format; st.save_options = old.save_options.clone(); st.converted = old.converted; st.view = old.view.clone(); let old = std::mem::replace(&mut self.docs[index], st); if let Some(stash) = &mut self.batch_stash { stash.push(old); } true } pub fn close_document(&mut self, index: usize) -> bool { if index >= self.docs.len() { return false; } self.reset_tool_for_doc_switch(); // Closed (saved or discarded): nothing left to recover. let uid = self.docs[index].uid; cmd::recovery::forget(self, uid); let old = self.docs.remove(index); if let Some(stash) = &mut self.batch_stash { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; true } /// Execute a command by id. This is THE entry point for every frontend. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; if let Err(why) = (spec.enabled)(self) { return Err(EngineError::Disabled(id.to_string(), why)); } // Only top-level commands are journaled (commands that call other commands would otherwise // be recorded twice and replay differently). if self.depth == 0 { self.journal_note.clear(); self.note_depth = 1; self.batch_stash = None; } let r = if self.depth == 0 { self.run_guarded(id, |s| (spec.run)(s, params)) } else { self.run_nested(|s| (spec.run)(s, params)) }; let r = r?; if self.depth == 0 { self.inherit_new_art(); } if spec.journal && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) { let p = self.noted(params); self.journal.push((id.to_string(), p)); } if self.depth == 0 { self.after_command(); } Ok(r) } /// Record `key: value` in the running top-level command's journal entry (or its interaction's /// preview, or its step in a batch) unless its params give `key`: a value it resolved from the /// preferences or the clock. pub fn note_journal(&mut self, key: &str, value: Value) { if self.depth == self.note_depth { self.journal_note.insert(key.to_string(), value); } } /// Run `id` as a step of the running command, which journals its steps (`command.batch`): /// → the step's result and its params with what it noted ([`Session::note_journal`]), for the /// step in the running command's journal entry. pub(crate) fn execute_step(&mut self, id: &str, params: &Value) -> Result<(Value, Value)> { let outer = (std::mem::take(&mut self.journal_note), self.note_depth); self.note_depth = self.depth + 1; let r = self.execute(id, params); let noted = self.noted(params); (self.journal_note, self.note_depth) = outer; Ok((r?, noted)) } /// The journal from entry `start` on, as an action records it: without the params that only /// pin a replay to the original run ([`REPLAY_ONLY`]), so playing the action later acts now. pub fn journal_for_action(&self, start: usize) -> Vec<(String, Value)> { self.journal .iter() .skip(start) .cloned() .map(|(id, mut p)| { strip_replay_only(&id, &mut p); (id, p) }) .collect() } /// `params` with the noted values added (see [`Session::note_journal`]). fn noted(&mut self, params: &Value) -> Value { let mut note = std::mem::take(&mut self.journal_note); match params { // The params' own values win. Value::Object(m) if !note.is_empty() => { note.extend(m.clone()); Value::Object(note) } Value::Null if !note.is_empty() => Value::Object(note), _ => params.clone(), } } fn run_nested(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { self.depth += 1; let r = f(self); self.depth -= 1; r } /// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active /// document goes back to how it was before the command, instead of crashing the frontend. fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone())); let active = self.active; match guard::catch_panic(|| self.run_nested(f)) { Ok(r) => r, Err(msg) => { self.depth = 0; self.journal_note.clear(); if let Some((uid, doc, selection, interaction)) = snapshot && let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) { st.doc = doc; st.selection = selection; st.interaction = interaction; st.revision += 1; } if active.is_some_and(|i| i < self.docs.len()) { self.active = active; } Err(EngineError::Internal { cmd: id.to_string(), msg }) } } } /// Run `f` on a mutable copy of the active document. Outside an interaction this records one /// undo step labelled `label`; inside one it just mutates (the interaction commits later). pub fn edit(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result) -> Result { let st = self.doc_mut()?; let before = st.doc.clone(); let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); let result = match f(doc, &mut st.selection) { Ok(v) => { // Text Wrap: area type follows its wrap objects; then threads re-flow. cmd::textwrap::refresh(Arc::make_mut(&mut st.doc)); // Opacity-mask editing: the mask follows its art on the editing layer. if st.doc.mask_edit.is_some() { cmd::maskedit::sync(Arc::make_mut(&mut st.doc)); } // Threaded text re-flows when any of its frames changed. if !st.doc.text_threads.is_empty() { cmd::threads::reflow(&before, Arc::make_mut(&mut st.doc)); } // Asset Export: assets let go of deleted art. if !st.doc.assets.is_empty() { Arc::make_mut(&mut st.doc).prune_assets(); } if doc_sane(&st.doc, &st.selection) { Ok(v) } else { Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) } } Err(e) => Err(e), }; match result { Ok(v) => { st.selection.prune(&st.doc); st.revision += 1; if st.interaction.is_none() { st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before, selection: before_sel }); if st.history.undo.len() > st.history.limit { st.history.undo.remove(0); } st.history.redo.clear(); } Ok(v) } Err(e) => { st.doc = before; st.selection = before_sel; Err(e) } } } /// Change only the selection (not an undo step). pub fn select(&mut self, f: impl FnOnce(&Document, &mut Selection)) -> Result<()> { self.active_appearance_item = None; let st = self.doc_mut()?; f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); st.revision += 1; Ok(()) } // ---------- interactions (live drags) ---------- pub fn begin_interaction(&mut self, label: &str) -> Result<()> { let st = self.doc_mut()?; if st.interaction.is_some() { return Ok(()); } st.interaction = Some(Interaction { label: label.to_string(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None, active_layer: st.active_layer, isolation: st.isolation, perspective_again: None, }); Ok(()) } /// Re-apply `cmd` on top of the interaction snapshot (replacing the previous preview). pub fn preview(&mut self, cmd: &str, params: &Value) -> Result { { let st = self.doc_mut()?; let Some(it) = &st.interaction else { return Err(EngineError::Other("no interaction in progress".into())) }; st.doc = it.doc.clone(); st.selection = it.selection.clone(); } let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params); let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); let r = self.execute(cmd, params); let params = self.noted(params); let st = self.doc_mut()?; if let Some(it) = &mut st.interaction { it.preview = Some((cmd.to_string(), params)); } r } pub fn commit_interaction(&mut self) -> Result<()> { self.remember_pending_paint(); let st = self.doc_mut()?; let Some(mut it) = st.interaction.take() else { return Ok(()) }; let Some(preview) = it.preview.take() else { return Ok(()) }; let perspective_again = it.perspective_again.take(); st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); if let Some(m) = m { st.last_transform = Some((m, copy)); st.last_perspective = None; } } if perspective_again.is_some() { st.last_perspective = perspective_again; } self.journal.push(preview); Ok(()) } /// Remember the paint a live preview applied (as its interaction is kept). pub(crate) fn remember_pending_paint(&mut self) { if let Some(p) = self.pending_paint.take() { self.remember_paint_now(&p); } } pub fn cancel_interaction(&mut self) -> Result<()> { self.pending_paint = None; self.doc_mut()?.undo_interaction(); Ok(()) } pub fn in_interaction(&self) -> bool { self.active().is_some_and(|d| d.interaction.is_some()) } /// Commands with enablement (for menus, palette, MCP `list_commands`). pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } } #[cfg(test)] mod tests; #[cfg(test)] mod tests_adjust; #[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_assets; #[cfg(test)] mod tests_attributes; #[cfg(test)] mod tests_bboxrotate; #[cfg(test)] mod tests_brushsym; #[cfg(test)] mod tests_build; #[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; #[cfg(test)] mod tests_clipboard; #[cfg(test)] mod tests_clipflavours; #[cfg(test)] mod tests_clippaint; #[cfg(test)] mod tests_cmdsplit; #[cfg(test)] mod tests_cmykflatten; #[cfg(test)] mod tests_colorguide; #[cfg(test)] mod tests_colorguidelib; #[cfg(test)] mod tests_colormgmt; #[cfg(test)] mod tests_colorthemes; #[cfg(test)] mod tests_containers; #[cfg(test)] mod tests_css; #[cfg(test)] mod tests_cut; #[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_distort; #[cfg(test)] mod tests_docsetup; #[cfg(test)] mod tests_draw2; #[cfg(test)] mod tests_editcolors; #[cfg(test)] mod tests_effectedit; #[cfg(test)] mod tests_emptytype; #[cfg(test)] mod tests_envelope; #[cfg(test)] mod tests_expand; #[cfg(test)] mod tests_eyedropper; #[cfg(test)] mod tests_file; #[cfg(test)] mod tests_fileinfo; #[cfg(test)] mod tests_flatpresets; #[cfg(test)] mod tests_flatpreview; #[cfg(test)] mod tests_flatten; #[cfg(test)] mod tests_focal; #[cfg(test)] mod tests_fontlist; #[cfg(test)] mod tests_freeform; #[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; #[cfg(test)] mod tests_halftone; #[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_knockout; #[cfg(test)] mod tests_labspots; #[cfg(test)] mod tests_layerclip; #[cfg(test)] mod tests_linked_stops; #[cfg(test)] mod tests_links; #[cfg(test)] mod tests_linkspanel; #[cfg(test)] mod tests_liquify; #[cfg(test)] mod tests_live; #[cfg(test)] mod tests_maskview; #[cfg(test)] mod tests_menucmds; #[cfg(test)] mod tests_nativefile; #[cfg(test)] mod tests_newart; #[cfg(test)] mod tests_newdoc; #[cfg(test)] mod tests_objexpand; #[cfg(test)] mod tests_opacitymask; #[cfg(test)] mod tests_outlinestroke; #[cfg(test)] mod tests_overprint; #[cfg(test)] mod tests_package; #[cfg(test)] mod tests_paintproxy; #[cfg(test)] mod tests_panelcmds; #[cfg(test)] mod tests_pathops; #[cfg(test)] mod tests_pattern; #[cfg(test)] mod tests_pdffidelity; #[cfg(test)] mod tests_pdfmarks; #[cfg(test)] mod tests_pdfpresets; #[cfg(test)] mod tests_pdfraster; #[cfg(test)] mod tests_persp_select; #[cfg(test)] mod tests_perspgrid; #[cfg(test)] mod tests_place; #[cfg(test)] mod tests_plugins; #[cfg(test)] mod tests_prefs; #[cfg(test)] mod tests_previewbounds; #[cfg(test)] mod tests_print; #[cfg(test)] mod tests_printadvanced; #[cfg(test)] mod tests_printpresets; #[cfg(test)] mod tests_printpreview; #[cfg(test)] mod tests_printps; #[cfg(test)] mod tests_printtiling; #[cfg(test)] mod tests_proxyitems; #[cfg(test)] mod tests_puppetwarp; #[cfg(test)] mod tests_rastersettings; #[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_recovery; #[cfg(test)] mod tests_registration; #[cfg(test)] mod tests_save; #[cfg(test)] mod tests_saveoptions; #[cfg(test)] mod tests_scalestrokes; #[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_strokegeom; #[cfg(test)] mod tests_strokegradient; #[cfg(test)] mod tests_strokereach; #[cfg(test)] mod tests_strokeux; #[cfg(test)] mod tests_stylelib; #[cfg(test)] mod tests_stylepanel; #[cfg(test)] mod tests_styles; #[cfg(test)] mod tests_swatchcmds; #[cfg(test)] mod tests_swatches; #[cfg(test)] mod tests_swatchlib; #[cfg(test)] mod tests_targeting; #[cfg(test)] mod tests_textedit; #[cfg(test)] mod tests_textimport; #[cfg(test)] mod tests_tileedge; #[cfg(test)] mod tests_tints; #[cfg(test)] mod tests_toolsettings; #[cfg(test)] mod tests_transparencygrid; #[cfg(test)] mod tests_units; #[cfg(test)] mod tests_webexport; #[cfg(test)] mod tests_widthpoints; #[cfg(test)] mod tests_widthprofiles; #[cfg(test)] mod tests_xform;