Files
designcraft/crates/engine/src/lib.rs
T
aiongg 18ae8e4761 Merge upstream main a184db6f8 with our 15 PRs (batch-0) into next
Conflicts resolved per file (both sides kept unless noted):
crates/engine/src/atomic.rs | 1 | main's: `write_file` removed (main routes command writes through `crate::io::write_file`); its one caller in file.rs now uses `crate::io::write_file`
crates/engine/src/cmd/datamerge/fill.rs | 1 | next's `Graphic` literal (has `clip` and `wrap`, which next's `Graphic` carries)
crates/engine/src/cmd/datamerge/mod.rs | 1 | main's `read_path_capped` (crate::io metadata/read_capped, roots checked); next's capped read is the same behaviour
crates/engine/src/cmd/datamerge/parse.rs | 1 | main's (`crate::io::metadata`, `crate::io::Metadata`)
crates/engine/src/cmd/edit.rs | 1 | next's `spread_param(p, "edit.paste")` (the second argument is the command id on both sides)
crates/engine/src/cmd/export.rs | 3 | main's: EPUB params doc, `toc_title` once (main's position; next's duplicate dropped)
crates/engine/src/cmd/file.rs | 7 | place doc: next's (lists page/rect/autoflow params #803 checks); next's recent-file functions + main's `foreign_document`; next's open (load_bytes, warnings, non-DesignCraft refusal) with main's checked `crate::io::read_file` for the bytes; main's `check_write` be
crates/engine/src/cmd/find.rs | 7 | main's Find scope (FindContext, scope/selection/story, continuation, UTF-8 repair) on next's `designcraft_compose::grep::Grep` (dialect GREP): find uses `find_all` capped at MAX_MATCHES, change uses `replacements`/`find_all` (literal change text not expanded), fin
crates/engine/src/cmd/fonts.rs | 1 | next's (`has_style`, resolved/replacement face); `has_style` already compares styles ignoring spacing and case, which is main's `same_style` fix; main's test kept
crates/engine/src/cmd/interchange.rs | 1 | next's `import_idml_report_with_previews` (previews for unreadable links)
crates/engine/src/cmd/layout.rs | 8 | pages.insert: main's overflow-safe `after` + next's checked `parent_ref`; main's duplicateSpread doc; layout.section: next's `SectionEdit` (already keeps unnamed settings, #717) with main's `d.repaginate()` moved into `SectionEdit::apply` (remove and set paths),
crates/engine/src/cmd/links.rs | 5 | next's links.list (no doc clone); main's `text_status` via crate::io; `use std::io::Read` dropped (unused after main's read_capped); test literals main's. `link_missing` (next) uses `crate::io::exists` for the file-guard scan
crates/engine/src/cmd/object.rs | 4 | object.fill/stroke tint-alone: main's (23e1680e7; out-of-range tint is an error, which main's test asserts); object.fit doc: next's (lists `axis`)
crates/engine/src/cmd/paths.rs | 1 | next's (shatai transform on outlines)
crates/engine/src/cmd/place_text.rs | 1 | next's `add_flow_frame` (used by autoflow)
crates/engine/src/cmd/preflight.rs | 2 | next's overset/unplacedStory issue (its `overset_location` already applies the parent transform main's #738 fix adds); next's two test modules kept with main's
crates/engine/src/cmd/scripts.rs | 1 | combined: next's `in_undo_step = false` (#406) + main's full rollback of a failed script (#709)
crates/engine/src/cmd/style.rs | 14 | param docs next's (gradient swatch.create, swatch stops, model colours); create_para next's; rename_style main's structure (rename entry, then `redirect_style_refs`), redirect has next's full reference set (cells, defaults, notes, TOC) + main's never-based-on-it
crates/engine/src/cmd/text.rs | 10 | main's selectSentence/Paragraph/Story + select_around; next's selectionAttrs (mixed) + main's openTypeFeatures/type.feature; main's comments; tracked typing: next's `reserve_command_text` + main's `insert_format_at`; empty-paragraph mark formatting main's (with `
crates/engine/src/cmd/thread.rs | 1 | next's `spread_param(p, "text.thread")`
crates/engine/src/dtd.rs | 1 | next's linear, depth-bounded `expand_parameter_entities` with main's budget (input + 1 MiB, `MAX_EXPANDED_BYTES`) and message ("expand the DTD beyond"); 16 MiB constant removed; both sides' tests pass on message
crates/engine/src/guard.rs | 2 | main's whole-transaction snapshot (docs, active, journal; #742) + next's `in_undo_step` restore; tests both
crates/engine/src/lib.rs | 5 | LoadedGraphic doc next's; next's `new_document_preview`; execute: main's `discard_stale_find_scope()` before next's nested-undo run; modules both (tests_autosize, tests_file_guard, tests_params, tests_roots)
crates/engine/src/recovery.rs | 1 | next's test (superset of main's assertion)
crates/engine/src/tests.rs | 4 | moved tests deduplicated (identical on both sides); next's preflight and direct-selection/kerning tests + main's style-delete, locked-frame and baseline-fit tests
crates/engine/src/tests_idml.rs | 3 | main's order (drop cap and margins tests identical on both sides, once each)
crates/ui-egui/src/canvas.rs | 3 | next's screen_options/DisplayKey block and tests kept; two tests that auto-merge had added twice (parent_guides_on_a_page_are_not_picked_there, baseline_grid_lines_are_the_ones_on_the_page) kept once; main's `typing`/`screen_at`/`click` helpers and its tests clicks
crates/ui-egui/src/control.rs | 3 | main's `count` check and pending budget (control queue + synthetic queue); exact-budget test runs both sides' cases (click 255 on 9,492 pending; drag 9492×255)
crates/ui-egui/src/cursors.rs | 1 | main's (same Pen cursor set)
crates/ui-egui/src/dialogs.rs | 14 | dialog body (newDocument … alert match) is next's text, with main's Find/Change scope reset (`set_field`, `reset_find_scope`, `find_change_params`) and main's Create Guides / Margins and Columns arms ported in; `Dialog::new` ruby section (next) + main's `set_fi
crates/ui-egui/src/i18n.rs | 7 | ka and pl modules, languages, statics and lookups both; `category_first` covers uk, ru, pl, ka and `count_label` keeps next's Czech plurals; shared TABLE union; next's duplicate rows for Auto, Number:, Gutter:, Top:, Bottom:, Inside:, Outside:, Left:, Right: dropped
crates/ui-egui/src/i18n/ar.rs | 2 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/it.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/ja.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/pt_br.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/i18n/uk.rs | 1 | union of rows, no duplicate keys
crates/ui-egui/src/icons.rs | 2 | both tests (main's typography icon, next's distribute icons)
crates/ui-egui/src/lib.rs | 11 | UiState: next's tabs_panel + main's last_color/last_gradient; `cursors` field once; next's SVG paste and paste-on-spread-in-view; next's extensionless document open; opens_as_document next's comment; tests both (next's control bar / key-name tests, main's rich black
crates/ui-egui/src/menus.rs | 15 | app.language doc next's text with pl and ka; UI_COMMANDS both (next's Tabs, Toggle Presentation, Apply None text; main's panel commands, Apply Color, Apply Gradient); Edit › Interface Language stays out (next moved it to Preferences); Type menu main's order with
crates/ui-egui/src/panels/glyphs.rs | 1 | next's (already has main's scroll-bar fix); the scroll-bar test module auto-merge had added twice kept once
crates/ui-egui/src/panels/layers.rs | 3 | next's drag-and-drop `object_rows`, with main's features ported into it: selection reveal (layer opens, row scrolls into view once), selected rows highlighted (also a group whose member is selected), double-click rename in place (`object.rename` queued like
crates/ui-egui/src/panels/mod.rs | 6 | tabs + typography modules and color_panel_tests; SelInfo has wrap_side/wrap_contour and wrap_offsets (offsets from the graphic's own wrap when it is selected); next's swatch_picker returning the chip response; next's content_selection_tests
crates/ui-egui/src/panels/pages.rs | 1 | next's (parents dragged by id)
crates/ui-egui/src/panels/properties.rs | 4 | next's swatch popup (Apply To, Tint); main's four wrap offsets then next's Wrap To / Contour Type; test fixtures main's (`..Default::default()`, no unused builder)
crates/ui-egui/src/panels/swatches.rs | 3 | main's Delete Swatch button + next's New Swatch comment; next's draggable rows; click: main's PICKED + fill/stroke in front, with next's no-apply on double-click
crates/ui-egui/src/toolbar.rs | 2 | main's Apply Color / Gradient / None wells and functions; next's apply_none doc line
crates/compose/src/lib.rs | 5 | next's line loop kept (mark_leading + last_line_mark, wrap segments/carry, grid gyoudori); main's mark-chars base_leading block (auto-merged) stays beside it. `fits`: next's #106 rule (baseline fit for horizontal frames, full descent for paths, vertical text, footnote
crates/compose/src/tests.rs | 5 | next's tests (tombstone set, sliver/one-sided/contour/clipping wrap tests, borders, lists, kenten, grid, anchored wrap, balance, shaped frames) plus main's an_empty_last_paragraph_isnt_overset; main's wrap_object_beside_one_narrow_column_leaves_the_other_alone re-ad
crates/doc/src/assets.rs | 1 | main's doc comment (same meaning)
crates/doc/src/build.rs | 3 | parent_page_for: next's parent_page_for_side (RTL-aware); single_sided_page_shows_first_parent_page test kept once, at main's position
crates/doc/src/item.rs | 1 | next's rotated_group_bounds_follow_the_children_outlines test kept
crates/doc/src/lib.rs | 1 | next's kerning_increment field kept; main's "Bulleted & Numbered" wording
crates/doc/src/page.rs | 1 | next's outer_page_indices + parent_page_for_side (integration of parent-margins with rtl-master-sides); main's parent_page_index dropped, its only caller (build.rs) uses parent_page_for_side
crates/doc/src/slice.rs | 5 | extract: next's `marked()` (get-based, fix_notes/fix_endnotes/fix_marks; main's version indexed slices and could panic); insert_story: main's take() limit on endnote/editorial payload copies with next's id comment, redundant `let ne`/`let nn` dropped; main's excess_note
crates/geom/src/corners.rs | 1 | next's box_outline_rounds_only_closed_corners test kept
crates/render/src/damage.rs | 1 | next's wrapping-anchored-object damage test kept
crates/render/src/fx.rs | 2 | feather: next's choke/corners sigma+join inside main's structure (body and clip at device resolution, with_filters(.., None)); inner glow centre: next's blend `mix` with main's clip argument (bp, xf)
crates/render/src/lib.rs | 3 | next's layer comment; next's front baseline grid draw after items; gradient match on g.kind (main's tuple scrutinee did not match the single-pattern arms)
crates/render/src/text.rs | 5 | paint_glyphs: main's paint_stroke closure structure with next's stroke overprint (overprinting helper, stroke `(TextPaint, bool)`); callers pass next's overprint_fill/overprint_stroke
crates/render/tests/placed_pdf.rs | 1 | main's `near` helper version
crates/tools/src/lib.rs | 1 | main's (PenConvert already declared below, avoids a duplicate variant)
crates/tools/src/pen.rs | 1 | main's module doc
crates/tools/src/select.rs | 1 | next's Drag::Corner variant (live corners) kept
crates/tools/src/tests.rs | 4 | next's tests (content drag, live corners, table border drag, eyedropper matchFrom, fitting handles, pen cursors once); eyedropper_ignores_inactive keeps next's samples_image check (main's exact-JSON compare lacks matchFrom) with main's `..Default::default()` Asset lit
crates/tools/src/text.rs | 2 | next's table border drag (BorderDrag, border_at) kept, then main's comment on locked text frames
crates/tools/src/xform.rs | 2 | main's explicit `Vec<&Item>` for shown children; next's eyedropper matchFrom (integration 796e9d713)
Cargo.toml | 2 | main's craft-pdf workspace dep added (craft-print stays once, in next's craft-libs block); [patch.crates-io] keeps next's winit (rust-windowing #4694 backport) and adds main's krilla patch; main's skylartaylor winit left out (see UNSURE 1)
Cargo.lock | 1 | union: next's craft-atomic and craft-cms entries plus main's craft-pdf entry; needs `cargo` normalisation
README.md | 1 | Open bullet combines INDD import via indd-utils (next) and editable PowerPoint export (main); main's paragraph break kept
contributors/people.toml | 1 | both people kept (switch87 from next, Nmarello from main)
apps/designcraft-cli/src/main.rs | 4 | next's `Export` enum dispatch kept; main's .pptx export added as `Export::Pptx` (run prints slides and warnings, script runs file.exportPptx); main's `--pdf-options` null-as-{} parse; usage lines list .pptx
apps/designcraft/src/main.rs | 3 | `startup.leave()` right after `app.ui` (main #380) and next's macOS `sync_movable` kept after the IME discard; next's Apple Events install kept; main's macOS Dock Quit `with_close_requested_on_terminate` block left out (needs the skylartaylor winit; UNSURE 1)
crates/fonts/src/fontdb.rs | 9 | FONT6 aliases (next) combined with #911 WWS names (main): ScannedFace and both CatalogEntry constructors carry `wws` and `aliases`; `canonical_family` falls back to WWS family names; `exact_style_traits` canonicalises then tries typographic, then WWS names (`named_st
crates/format/src/lib.rs | 1 | tests: both sides kept (next's r7a nullable-default tests; main's oversized ZIP entry/asset/entry-count tests)
crates/idml/src/import.rs | 11 | main's bounded `Package` reader under next's `import_idml_report_with_previews` (metadata read through `pkg.part`); next's `tab_list_repaired` kept; ruler origin: next's `settings.ruler_origin`, and main's guide import reads it (main's private `RulerOrigin` enum and
crates/idml/src/tests.rs | 6 | item-level union: all next tests plus main's new ones (ruler guides, package limits, CJK leading model, liquid page rules module, mojikumi); no test dropped
crates/mcp/src/backend.rs | 1 | test comment merged from both
crates/pdf/src/tests.rs | 1 | tests: both sides kept
crates/pdf/src/text.rs | 4 | next's (skew `tan`, cluster `start`, ruby overprint warning); main had no change here beyond base
crates/textimport/src/docx.rs | 5 | next's (ListCounter integration for list levels, page-break test position); main's side was the older form of the same code
crates/epub/src/lib.rs | 30 | non-test code is next's (StoryWriter holds img_src/images/no_alt/heading_ids; covers main's #787/#791/#782 fixes, plus #314 group transforms, anchored images in images_without_alt, endnote story skipped in the TOC); tests: next's set plus main's extra css_weight_style a
docs/control-protocol.md | 2 | next's rows (ui.paste, count 1–255, documentGrid) and wording
docs/file-format-parity.md | 1 | rows combined (next's ICC notes, main's TIFF indexed colour)
docs/gaps.md | 2 | status change notes unioned; Flex Layout removed from missing (main implemented it), next's Parent Options sentence kept
docs/localization-parity.md | 4 | Polish and Georgian rows; 5 languages beyond the twelve; file list includes cs and ka; both history rows
docs/roadmap.md | 1 | M1 next's (#186 fixed on next), M2 main's IME wording
docs/target-app-parity.md | 6 | next's status line; File formats row next's, UI/UX row main's (IME no longer failing); UI line next's plus main's Swatches detail; one Page tool line (main's fuller one); Load styles and main's fuller Pathfinder line both kept, main's duplicate short Pathfinder line r
docs/ui-parity.md | 1 | IME row main's, Font menu row next's

i18n/fr.rs: five rows dropped that main's shared table now has (Row, Open Path, Flex Layout, Current Page Number, Rulers).
2026-10-16 03:09:22 +00:00

741 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The DesignCraft engine façade.
//!
//! Every user-visible action is a command with a stable id (`frame.create`, `text.insert`,
//! `layout.pages.insert`…) and JSON parameters. The egui UI, the CLI, the control channel and MCP
//! all go through [`Session::execute`]. Tools (pointer gestures) are hosted here and reduce to
//! commands, so every gesture is journaled and replayable.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(not(target_arch = "wasm32"))]
pub mod atomic;
pub mod autosize;
pub mod cmd;
pub mod dtd;
pub mod file_access;
pub mod guard;
pub mod io;
pub mod links;
pub mod math;
pub mod recovery;
pub mod sample;
pub mod script;
mod tooling;
use std::sync::Arc;
use designcraft_compose::Cache;
use designcraft_doc::{Document, LayerId, Selection};
use serde_json::Value;
pub use cmd::params::{check_keys, check_params};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use designcraft_compose as compose;
pub use designcraft_doc as doc;
pub use designcraft_tools as tools;
pub use tooling::{SnapView, UiRequest, ViewInfo};
#[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("{0}")]
Other(String),
/// A command panicked; the guard kept the document (a bug: please report it).
#[error("internal error in `{0}` (the document was kept as it was): {1}")]
Internal(String, String),
}
impl From<designcraft_doc::DocError> for EngineError {
fn from(e: designcraft_doc::DocError) -> Self {
EngineError::Other(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, EngineError>;
#[derive(Clone, Debug)]
pub struct HistoryEntry {
pub label: String,
pub doc: Arc<Document>,
pub selection: Selection,
}
#[derive(Clone, Debug, Default)]
pub struct History {
pub undo: Vec<HistoryEntry>,
pub redo: Vec<HistoryEntry>,
pub limit: usize,
}
#[derive(Clone, Debug)]
pub struct Interaction {
pub label: String,
pub doc: Arc<Document>,
pub selection: Selection,
pub preview: Option<(String, Value)>,
/// The document's revision when the gesture began: a cancel puts it back.
pub revision: u64,
}
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Document>,
pub selection: Selection,
pub history: History,
pub path: Option<String>,
/// Names the document state for caches keyed `(uid, revision)` (see
/// [`DocState::bump_revision`]).
pub revision: u64,
pub saved_revision: u64,
/// The document as last saved (dirty = the current document is a different allocation).
pub saved_doc: Arc<Document>,
pub active_layer: LayerId,
pub interaction: Option<Interaction>,
/// Editing parent spreads (Pages panel double-click on a parent).
pub editing_parents: bool,
pub uid: u64,
/// Recovery entries this state actually opened or published, for repeated-open and cleanup.
/// Bounded, deduplicated checkpoint addresses; never inferred from uid alone.
pub(crate) recovery_entries: Vec<(std::path::PathBuf, u64)>,
/// Unfilled document while data-merge preview is showing a record. Not saved.
pub preview_stash: Option<Arc<Document>>,
/// 1-based record currently previewed.
pub preview_record: Option<u32>,
/// The fonts the document brought (`doc.font_scope`), found while it is open.
pub fonts: Option<Arc<FontScope>>,
}
/// A document's font scope ([`designcraft_fonts::FontDb::load_document_fonts`]): its fonts are
/// found until the document's state (with every copy of it) is gone — closed, reverted, or its
/// session ended.
#[derive(Debug)]
pub struct FontScope(pub u32);
/// A graphic loaded in the place cursor (`place.load`, File › Place with no frame to fill).
#[derive(Clone, Copy, Debug)]
pub struct LoadedGraphic {
pub asset: designcraft_doc::AssetId,
/// Natural size in points: the Image Import Options › Crop to box for a PDF page (the asset's
/// `pdf_box`).
pub size: (f64, f64),
}
impl Drop for FontScope {
fn drop(&mut self) {
designcraft_fonts::FontDb::global().close_scope(self.0);
}
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
impl DocState {
pub fn new(doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
let doc = Arc::new(doc);
let revision = next_revision();
DocState {
saved_doc: doc.clone(),
doc,
selection: Selection::default(),
history: History { limit: 1000, ..Default::default() },
path,
revision,
saved_revision: revision,
active_layer,
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
recovery_entries: Vec::new(),
preview_stash: None,
preview_record: None,
fonts: None,
}
}
/// The document changed: it gets a revision no document state had before. A cancelled
/// gesture puts back the revision it began at, so the numbers its previews used are never
/// handed out again (a cache entry for one would show the cancelled preview).
pub fn bump_revision(&mut self) {
self.revision = next_revision();
}
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved_doc)
}
pub fn title(&self) -> String {
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())
}
}
/// Preferences that the engine needs (UI keeps its own).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Prefs {
pub show_hidden_characters: bool,
pub typographers_quotes: bool,
/// Polygon Settings (double-click the Polygon tool): number of sides, star inset (0–1).
pub polygon_sides: u32,
pub star_inset: f64,
/// Scaling objects scales their stroke weights (Preferences › General › When Scaling).
pub scale_strokes: bool,
/// X/Y/W/H in the Control and Properties panels measure the stroke's outer edge.
pub dimensions_include_stroke: bool,
/// Preferences › General › Transformations are Totals: rotation, shear and scale of nested
/// objects (a graphic in a frame, objects in groups) are measured on the pasteboard rather
/// than relative to their container.
pub transformations_are_totals: bool,
/// Preferences › General › Page Numbering View: Absolute (1, 2, 3… from the first page) instead
/// of section numbering (the page names, e.g. "iv", "A-3").
pub absolute_page_numbers: bool,
/// Preferences › Composition › Highlight: H&J violations, custom tracking/kerning, substituted
/// fonts (screen only).
pub highlight_hj: bool,
pub highlight_keeps: bool,
pub highlight_custom_tracking: bool,
pub highlight_substituted_fonts: bool,
/// Preferences › Appearance of Black › Printing / Exporting: 100% K as rich (pure) black in
/// RGB output (PNG) instead of the accurate dark grey.
pub rich_black_output: bool,
/// Font menu favourites (family names).
pub favorite_fonts: Vec<String>,
/// Fonts picked in the font menus, most recent first (shown at the menus' top).
pub recent_fonts: Vec<String>,
/// Preferences › Type › Show Font Names in English: the font menus show CJK families by their
/// English names instead of their native ones. Documents store the English name either way.
pub show_font_names_in_english: bool,
/// Preferences › Type › Show CJK Features: the menu items, dialogs and panel fields that only
/// Japanese, Chinese and Korean typesetting uses. `None` follows the interface language. It
/// hides interface only: layout, file formats and commands work either way.
pub cjk_features: Option<bool>,
/// Preferences › Type › Smart Text Reflow: pages follow the primary text frame's story
/// (added while it oversets, empty ones at the end removed).
pub smart_text_reflow: bool,
/// Preferences › Autocorrect: typing a space or punctuation after a listed word replaces it.
pub autocorrect: bool,
/// Misspelled word → correction (lowercase; the correction follows the typed capitalisation).
pub autocorrect_list: Vec<(String, String)>,
/// Preferences › Track Changes › Show: added text highlighted on screen.
pub show_added_text: bool,
/// Preferences › Notes: note anchors shown in layout view.
pub show_note_anchors: bool,
/// Preferences › File Handling: minutes between document recovery saves.
pub recovery_minutes: f64,
/// File › New › Document › Save Document Preset: saved document presets, by name (`title`).
pub document_presets: Vec<designcraft_doc::build::NewDocument>,
/// Preferences › File Handling › Create Links When Placing Text Files: placed text files are
/// linked (Links panel) unless `file.place` says otherwise.
pub link_text_files: bool,
/// File › Open Recent: documents opened or saved, most recent first.
pub recent_files: Vec<String>,
/// Preferences › File Handling › Number of Recent Items to Display (0 keeps no list).
pub recent_files_max: u32,
}
/// The most recent files Preferences lets File › Open Recent keep.
pub const MAX_RECENT_FILES: u32 = 100;
impl Prefs {
/// Move `path` to the front of File › Open Recent (no duplicates, at most `recent_files_max`).
pub fn note_recent_file(&mut self, path: &str) {
if path.is_empty() {
return;
}
self.recent_files.retain(|p| p != path);
self.recent_files.insert(0, path.to_string());
self.trim_recent_files();
}
/// Drop the entries beyond `recent_files_max` (capped at `MAX_RECENT_FILES`).
pub fn trim_recent_files(&mut self) {
self.recent_files_max = self.recent_files_max.min(MAX_RECENT_FILES);
self.recent_files.truncate(self.recent_files_max as usize);
}
}
/// A starter autocorrect list (common English typing slips).
pub fn default_autocorrect() -> Vec<(String, String)> {
[
("teh", "the"),
("adn", "and"),
("taht", "that"),
("recieve", "receive"),
("seperate", "separate"),
("occured", "occurred"),
("untill", "until"),
("wich", "which"),
("becuase", "because"),
("definately", "definitely"),
("thier", "their"),
("alot", "a lot"),
]
.iter()
.map(|(a, b)| (a.to_string(), b.to_string()))
.collect()
}
impl Default for Prefs {
fn default() -> Self {
Prefs {
document_presets: Vec::new(),
show_hidden_characters: false,
typographers_quotes: true,
polygon_sides: 6,
star_inset: 0.0,
scale_strokes: true,
dimensions_include_stroke: true,
transformations_are_totals: true,
absolute_page_numbers: false,
highlight_hj: false,
highlight_keeps: false,
highlight_custom_tracking: false,
highlight_substituted_fonts: true,
rich_black_output: false,
favorite_fonts: Vec::new(),
recent_fonts: Vec::new(),
show_font_names_in_english: false,
cjk_features: None,
smart_text_reflow: true,
autocorrect: false,
autocorrect_list: default_autocorrect(),
show_added_text: true,
show_note_anchors: true,
recovery_minutes: 0.5,
link_text_files: false,
recent_files: Vec::new(),
recent_files_max: 20,
}
}
}
pub struct Session {
docs: Vec<DocState>,
active: Option<usize>,
/// An undoable command is running: the commands it runs are part of its undo step.
in_undo_step: bool,
pub prefs: Prefs,
pub cache: Arc<Cache>,
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
pub journal: Vec<(String, Value)>,
pub(crate) find_context: Option<cmd::find::FindContext>,
pub clipboard: Option<Arc<Document>>,
/// The x the clipboard's objects are measured from on their spread: its spine, or the left edge of
/// single-sided pages. Paste keeps their distance from it.
pub clipboard_origin: f64,
/// Crash-recovery folder (the app sets it; saving or closing a document removes its entry).
pub recovery_dir: Option<std::path::PathBuf>,
/// Copied text with its formatting (a frameless story slice) and its plain text.
pub text_clipboard: Option<(Arc<designcraft_doc::Story>, String)>,
/// Requests for the UI (dialogs, view changes) produced by tools/commands.
pub ui_requests: Vec<UiRequest>,
/// Graphic loaded in the place cursor.
pub loaded: Option<LoadedGraphic>,
pub(crate) untitled: u32,
/// Transforms applied to the current selection, oldest first (Transform Again / Sequence
/// Again), with the selection they were applied to and whether the last one came from a
/// tool interaction (its previews replace each other).
pub(crate) transforms: (Vec<(String, Value)>, Vec<designcraft_doc::ItemId>, bool),
/// Drag and drop text editing: the press landed in the selected text.
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// The open Object Library (File › New / Open Library).
pub library: Option<cmd::library::Library>,
/// The open book (File › Open Book).
pub book: Option<cmd::book::Book>,
/// Content Collector conveyor: collected objects as snippets (name, bytes).
pub conveyor: Vec<(String, Vec<u8>)>,
/// New Document's Preview: the preview document's uid and the uid of the document that was
/// active before it (see [`Session::preview_new_document`]).
pub(crate) new_document_preview: Option<(u64, Option<u64>)>,
/// Linked text files checked by the Links panel: path → the size and modification time the
/// file had and the hash of its contents then, so a file touched but not changed is read once.
pub(crate) text_link_checks: std::collections::HashMap<String, (designcraft_doc::Fingerprint, String)>,
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
impl Session {
/// The name `file.new` gives the next document without a title (New Document shows it).
pub fn next_untitled_title(&self) -> String {
format!("Untitled-{}", self.untitled.saturating_add(1))
}
pub fn new() -> Self {
guard::install_panic_hook();
Session {
docs: vec![],
active: None,
in_undo_step: false,
prefs: Prefs::default(),
cache: Arc::new(Cache::new()),
tool: designcraft_tools::create("selection"),
journal: vec![],
find_context: None,
clipboard: None,
clipboard_origin: 0.0,
recovery_dir: None,
text_clipboard: None,
text_link_checks: Default::default(),
ui_requests: vec![],
loaded: None,
transforms: Default::default(),
text_drag: false,
typing_format: None,
library: None,
conveyor: Vec::new(),
book: None,
untitled: 0,
new_document_preview: None,
}
}
pub fn documents(&self) -> &[DocState] {
&self.docs
}
pub fn active_index(&self) -> Option<usize> {
self.active
}
/// A page's label as the UI shows it (Page Numbering View).
pub fn page_label(&self, abs: usize) -> String {
match self.active() {
Some(d) if !self.prefs.absolute_page_numbers => d.doc.page_name(abs),
_ => (abs + 1).to_string(),
}
}
/// The page a typed page reference means: `+n` is always absolute; otherwise a section page
/// name (section numbering) or a position (absolute numbering, or no page has that name).
pub fn resolve_page(&self, s: &str) -> Option<usize> {
let d = self.active()?;
let n = d.doc.page_count();
let s = s.trim();
let pos = |t: &str| t.parse::<usize>().ok().filter(|k| (1..=n).contains(k)).map(|k| k - 1);
if let Some(t) = s.strip_prefix('+') {
return pos(t);
}
if !self.prefs.absolute_page_numbers
&& let Some(i) = (0..n).find(|i| d.doc.page_name(*i).eq_ignore_ascii_case(s))
{
return Some(i);
}
pos(s)
}
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)
}
/// Release the transient Find/Change domain when its caller starts or closes a search.
pub fn reset_find_scope(&mut self) {
self.find_context = None;
}
fn discard_stale_find_scope(&mut self) {
if self.find_context.as_ref().is_some_and(|c| self.active().is_none_or(|st| !c.is_current(st))) {
self.reset_find_scope();
}
}
pub fn set_active(&mut self, i: usize) {
if i < self.docs.len() && self.active != Some(i) {
// The composition belongs to the document it shows in.
if let Err(e) = self.end_composition() {
log::warn!("ending the IME composition: {e}");
}
self.reset_find_scope();
self.stop_data_preview();
self.active = Some(i);
}
}
pub fn add_document(&mut self, d: DocState) -> usize {
if let Err(e) = self.end_composition() {
log::warn!("ending the IME composition: {e}");
}
self.reset_find_scope();
self.stop_data_preview();
self.docs.push(d);
self.active = Some(self.docs.len() - 1);
self.docs.len() - 1
}
/// Put the unfilled template back. Preview is session state and must not be saved.
pub fn stop_data_preview(&mut self) {
let Some(st) = self.active_mut() else { return };
st.preview_record = None;
let Some(stash) = st.preview_stash.take() else { return };
st.doc = stash;
st.bump_revision();
}
pub fn close_document(&mut self, i: usize) {
if self.active == Some(i) {
self.stop_data_preview();
}
#[cfg(not(target_arch = "wasm32"))]
{
let recovery_dir = self.recovery_dir.clone();
recovery::discard_document(self, i, recovery_dir.as_deref());
}
if i < self.docs.len() {
self.reset_find_scope();
self.docs.remove(i);
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
if self.docs.is_empty() {
// Retire this document generation's cache. In-flight render jobs may still
// own it, but must not repopulate the idle session's cache after closing.
self.cache = Arc::new(Cache::new());
}
}
}
/// Run a command by id. Edits push one undo step (unless inside an interaction).
/// Run command `id`. A panic inside it becomes [`EngineError::Internal`] and leaves the
/// document as it was (see [`guard`]).
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
// A command from outside the IME ends its composition first, keeping the marked text as
// typed: Undo then takes back that typing, and nothing runs on top of the preview the
// composition would drop. Queries and recovery saves leave it alone.
if self.tool.composing() && id != "file.recovery.save" && find_command(id).is_some_and(|c| c.journal || c.undoable) {
self.end_composition()?;
}
self.guarded(id, |s| s.execute_unguarded(id, params))
}
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
// The crate's own tests are the audit of the params descriptions (#776): a key a test
// passes that its command's description doesn't name fails the test.
#[cfg(test)]
check_params(id, params)?;
if let Err(e) = (spec.enabled)(self) {
// A command that takes `ids` acts on the objects the call names, so the selection
// doesn't matter: with a text caret, nothing selected, or a selection the command
// can't act on, `{"ids": [5]}` still runs.
let named = if cmd::selection_reason(&e) { cmd::named_targets(spec, params) } else { None };
let Some(ids) = named else { return Err(EngineError::Disabled(id.into(), e)) };
let st = self.doc()?;
if let Some(gone) = ids.iter().find(|i| st.doc.item(**i).is_none()) {
return Err(EngineError::Disabled(id.into(), format!("no object with id {}", gone.0)));
}
}
// Preview is temporary. Save, merge, and every other edit restore the template first.
// Queries stay read-only so the panel can poll fields during a preview. `data.preview`
// itself is the one command that fills the live document.
if id != "data.preview" && (spec.undoable || spec.journal) {
self.stop_data_preview();
}
// Preview restoration can replace the document before a Find handler resolves bounds.
self.discard_stale_find_scope();
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone()));
// What an undoable command runs is part of its undo step.
let nested = self.in_undo_step;
self.in_undo_step = nested || spec.undoable;
let ran = (spec.run)(self, params);
self.in_undo_step = nested;
let r = match ran {
Ok(r) => r,
Err(e) => {
// What it did before it failed can be undone too.
self.record_undo_step(spec, before, nested);
return Err(e);
}
};
// Undo, redo or deleting layers can take the active layer away: new objects would land on
// a layer that isn't there (invisible, not exported). Fall back to the top layer.
if let Some(st) = self.active_mut()
&& st.doc.layer(st.active_layer).is_none()
{
st.active_layer = st.doc.default_layer();
}
self.record_transform(id, params);
if self.prefs.smart_text_reflow && spec.undoable {
self.smart_reflow();
}
// Live captions follow their sources.
if spec.undoable
&& let Some(st) = self.active_mut()
&& st.interaction.is_none()
&& st.doc.spreads.iter().any(|sp| sp.items.iter().any(|i| i.live_caption.is_some()))
{
let mut d = (*st.doc).clone();
if cmd::captions::refresh_live(&mut d) {
st.doc = Arc::new(d);
}
}
// Auto-size frames fit the text the command changed, in the same undo step.
if spec.undoable
&& let Some(st) = self.active_mut()
{
let prev = before.as_ref().filter(|(uid, _, _)| *uid == st.uid).map(|(_, d, _)| d.as_ref());
if prev.is_none_or(|p| !std::ptr::eq(p, st.doc.as_ref())) {
let todo = autosize::candidates(&st.doc, prev);
if !todo.is_empty() {
let mut d = (*st.doc).clone();
if autosize::fit(&mut d, todo) {
st.doc = Arc::new(d);
st.bump_revision();
}
}
}
}
self.record_undo_step(spec, before, nested);
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
self.discard_stale_find_scope();
Ok(r)
}
/// Record undo if the document changed (and we're not previewing an interaction), with the
/// selection from before the command. A command run by an undoable one leaves the step to it:
/// one command is one undo step.
fn record_undo_step(&mut self, spec: &CommandSpec, before: Option<(u64, Arc<Document>, Selection)>, nested: bool) {
if let (Some((uid, old, selection)), Some(st)) = (before, self.active_mut())
&& st.uid == uid
&& !Arc::ptr_eq(&old, &st.doc)
&& st.interaction.is_none()
&& spec.undoable
&& !nested
{
let entry = HistoryEntry { label: spec.label.to_string(), doc: old, selection };
push_undo(st, entry);
}
}
/// Smart Text Reflow for the primary story: add threaded pages while it oversets; remove
/// trailing pages whose only object is an empty frame of it.
fn smart_reflow(&mut self) {
let Some(st) = self.active() else { return };
if st.interaction.is_some() {
return;
}
let Some(sid) = st.doc.settings.primary_story else { return };
let Some(story) = st.doc.story(sid) else { return };
let cs = self.cache.get(&st.doc, sid, None);
let overset = cs.overset_at.is_some();
let empty_tail = story.frames.len() > 1 && story.frames.last().is_some_and(|f| cs.frame(*f).is_none_or(|ft| ft.range.is_empty()));
if !overset && !empty_tail {
return;
}
let mut d = (*st.doc).clone();
if overset {
let _ = cmd::place_text_autoflow(&mut d, sid, 500, "Smart Text Reflow", true);
} else {
// Drop the last pages while all they hold is empty frames of the story.
while let Some(story) = d.story(sid) {
let Some(&last) = story.frames.last() else { break };
let Some(page) = d.page_of_item(last) else { break };
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
let on_page: Vec<_> =
d.spreads.iter().flat_map(|sp| sp.items.iter()).map(|i| i.id).filter(|id| d.page_of_item(*id) == Some(page)).collect();
let empty = |id: &designcraft_doc::ItemId| story.frames.contains(id) && cs.frame(*id).is_none_or(|ft| ft.range.is_empty());
if page + 1 != d.page_count() || d.page_count() < 2 || !on_page.iter().all(empty) || on_page.len() >= story.frames.len() {
break;
}
if on_page.iter().any(|id| d.remove_item(*id).is_err()) || d.delete_pages(&[page]).is_err() {
break;
}
}
}
if let Some(st) = self.active_mut() {
st.doc = Arc::new(d);
st.bump_revision();
}
}
/// Remember a move/rotate/scale/shear/flip for Transform Again.
fn record_transform(&mut self, id: &str, params: &Value) {
if !matches!(id, "transform.move" | "transform.rotate" | "transform.scale" | "transform.shear" | "transform.flip") {
return;
}
let Some(st) = self.active() else { return };
let sel = st.selection.items.clone();
let live = st.interaction.is_some();
let mut p = params.clone();
if let Some(o) = p.as_object_mut() {
o.remove("ids");
o.remove("id");
}
let (list, on, was_live) = &mut self.transforms;
if *on != sel {
list.clear();
*on = sel;
} else if *was_live && live && list.last().is_some_and(|l| l.0 == id) {
list.pop();
}
list.push((id.to_string(), p));
*was_live = live;
}
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().map(|c| c.info(self)).collect()
}
/// Mutate the active document (copy-on-write) and bump the revision.
pub fn edit<T>(&mut self, f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>) -> Result<T> {
let st = self.doc_mut()?;
let mut doc = (*st.doc).clone();
let mut sel = st.selection.clone();
let r = f(&mut doc, &mut sel)?;
crate::cmd::sync_placeholders(&mut doc);
crate::cmd::flex::sync(&mut doc);
doc.sync_endnote_story();
st.doc = Arc::new(doc);
st.selection = sel;
st.bump_revision();
self.reset_find_scope();
Ok(r)
}
}
fn push_undo(st: &mut DocState, e: HistoryEntry) {
st.history.undo.push(e);
st.history.redo.clear();
if st.history.undo.len() > st.history.limit.max(1) {
st.history.undo.remove(0);
}
}
pub(crate) use push_undo as record_undo;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_autosize;
#[cfg(test)]
mod tests_file_guard;
#[cfg(test)]
mod tests_idml;
#[cfg(test)]
mod tests_ime;
#[cfg(test)]
mod tests_params;
#[cfg(test)]
mod tests_roots;
#[cfg(test)]
mod tests_table;