Files
designcraft/crates/engine/src/lib.rs
T

406 lines
14 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.
#![forbid(unsafe_code)]
pub mod cmd;
pub mod links;
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::{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::{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),
}
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)>,
}
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Document>,
pub selection: Selection,
pub history: History,
pub path: Option<String>,
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,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
impl DocState {
pub fn new(doc: Document, path: Option<String>) -> Self {
let active_layer = doc.default_layer();
let doc = Arc::new(doc);
DocState {
saved_doc: doc.clone(),
doc,
selection: Selection::default(),
history: History { limit: 1000, ..Default::default() },
path,
revision: 1,
saved_revision: 1,
active_layer,
interaction: None,
editing_parents: false,
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
}
}
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 › 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,
}
impl Default for Prefs {
fn default() -> Self {
Prefs {
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,
smart_text_reflow: true,
}
}
}
pub struct Session {
docs: Vec<DocState>,
active: Option<usize>,
pub prefs: Prefs,
pub cache: Arc<Cache>,
pub(crate) tool: Box<dyn designcraft_tools::Tool>,
pub journal: Vec<(String, Value)>,
pub clipboard: Option<Arc<Document>>,
/// 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: (asset, natural size in points).
pub loaded: Option<(designcraft_doc::AssetId, (f64, f64))>,
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,
}
impl Default for Session {
fn default() -> Self {
Self::new()
}
}
impl Session {
pub fn new() -> Self {
Session {
docs: vec![],
active: None,
prefs: Prefs::default(),
cache: Arc::new(Cache::new()),
tool: designcraft_tools::create("selection"),
journal: vec![],
clipboard: None,
recovery_dir: None,
text_clipboard: None,
ui_requests: vec![],
loaded: None,
transforms: Default::default(),
text_drag: false,
untitled: 0,
}
}
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)
}
pub fn set_active(&mut self, i: usize) {
if i < self.docs.len() {
self.active = Some(i);
}
}
pub fn add_document(&mut self, d: DocState) -> usize {
self.docs.push(d);
self.active = Some(self.docs.len() - 1);
self.docs.len() - 1
}
pub fn close_document(&mut self, i: usize) {
#[cfg(not(target_arch = "wasm32"))]
if let (Some(dir), Some(d)) = (&self.recovery_dir, self.docs.get(i)) {
recovery::discard(dir, d.uid);
}
if i < self.docs.len() {
self.docs.remove(i);
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
}
}
/// Run a command by id. Edits push one undo step (unless inside an interaction).
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(spec.enabled)(self).map_err(|e| EngineError::Disabled(id.into(), e))?;
let before = self.active().map(|d| (d.uid, d.doc.clone()));
let r = (spec.run)(self, params)?;
self.record_transform(id, params);
if self.prefs.smart_text_reflow && spec.undoable {
self.smart_reflow();
}
// Record undo if the document changed (and we're not previewing an interaction).
if let (Some((uid, old)), Some(st)) = (before, self.active_mut())
&& st.uid == uid
&& !Arc::ptr_eq(&old, &st.doc)
&& st.interaction.is_none()
&& spec.undoable
{
let entry = HistoryEntry { label: spec.label.to_string(), doc: old, selection: st.selection.clone() };
push_undo(st, entry);
}
if spec.journal {
self.journal.push((id.to_string(), params.clone()));
}
Ok(r)
}
/// 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);
} else {
// Drop empty trailing frames on the last pages (one frame, nothing else there).
while let Some(story) = d.story(sid) {
if story.frames.len() < 2 {
break;
}
let last = *story.frames.last().expect("frames");
let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
if cs.frame(last).is_some_and(|ft| !ft.range.is_empty()) {
break;
}
let Some(page) = d.page_of_item(last) else { break };
let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).filter(|i| d.page_of_item(i.id) == Some(page)).count() == 1;
if page + 1 != d.page_count() || !alone || d.page_count() < 2 {
break;
}
if d.remove_item(last).is_err() || d.delete_pages(&[page]).is_err() {
break;
}
}
}
if let Some(st) = self.active_mut() {
st.doc = Arc::new(d);
st.revision += 1;
}
}
/// 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)?;
st.doc = Arc::new(doc);
st.selection = sel;
st.revision += 1;
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_idml;
#[cfg(test)]
mod tests_table;