//! The DeckCraft engine façade. //! //! Every user-visible action is a command with a stable id (`slide.new`, `shape.insert`, //! `text.insert`, `format.bold`…) and JSON parameters. The egui UI, the CLI, the control channel //! and MCP all go through [`Session::execute`]. Pointer tools reduce to commands too, so every //! gesture is journaled, undoable and replayable. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod cmd; pub mod connect; pub mod guard; pub mod keys; pub mod links; pub mod recovery; pub mod sample; pub mod tools; use std::sync::Arc; use deckcraft_model::{Presentation, Shape, ShapeId, Slide, SlideId}; use serde::{Deserialize, Serialize}; use serde_json::Value; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use deckcraft_geom as geom; pub use deckcraft_media as media; pub use deckcraft_model as model; pub use deckcraft_render as render; pub use tools::{Mods, PointerEvent, PointerKind, ToolKind}; #[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 presentation")] NoDocument, #[error("{0}")] Other(String), #[error("internal error in `{0}` (the presentation was kept as it was): {1}")] Internal(String, String), } impl From for EngineError { fn from(e: deckcraft_model::ModelError) -> Self { EngineError::Other(e.to_string()) } } pub type Result = std::result::Result; /// Text being edited in place: a shape's body (or a table cell, or the slide's notes). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TextSel { pub shape: ShapeId, /// Selection anchor and caret: (paragraph, char). pub anchor: (usize, usize), pub caret: (usize, usize), /// Editing a table cell (row, col). #[serde(default, skip_serializing_if = "Option::is_none")] pub cell: Option<(usize, usize)>, /// Editing the slide's speaker notes (shape is ignored). #[serde(default)] pub notes: bool, } impl TextSel { pub fn is_range(&self) -> bool { self.anchor != self.caret } pub fn ordered(&self) -> ((usize, usize), (usize, usize)) { if self.anchor <= self.caret { (self.anchor, self.caret) } else { (self.caret, self.anchor) } } } /// Which shape tree is being edited: slides (normal), or a master/layout (Slide Master view). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum Target { #[default] Slides, Master { master: usize, }, Layout { master: usize, layout: usize, }, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Selection { /// Current slide index. pub slide: usize, /// Slides selected in the thumbnail pane / sorter (includes the current one when non-empty). pub slides: Vec, /// Selected shapes on the current slide (top-level or inside groups). pub shapes: Vec, pub text: Option, /// Selected table cells: (table, (r0, c0), (r1, c1)). pub cells: Option<(ShapeId, (usize, usize), (usize, usize))>, pub target: Target, } #[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, pub limit: usize, } /// A live pointer interaction: the document before it began (one undo step at the end). #[derive(Clone, Debug)] pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Selection, } #[derive(Clone, Debug)] pub struct DocState { pub doc: Arc, pub selection: Selection, pub history: History, pub path: Option, pub revision: u64, pub saved_doc: Arc, pub interaction: Option, pub uid: u64, pub title: String, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); impl DocState { pub fn new(doc: Presentation, path: Option, title: String) -> Self { let doc = Arc::new(doc); DocState { saved_doc: doc.clone(), doc, selection: Selection::default(), history: History { limit: 500, ..Default::default() }, path, revision: 1, interaction: None, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), title, } } 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_stem()) .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| self.title.clone()) } pub fn current_slide(&self) -> Option<&Slide> { self.doc.slides.get(self.selection.slide).map(|s| s.as_ref()) } /// Shapes of the edited tree (current slide, or the master/layout in Slide Master view). pub fn shapes(&self) -> &[Shape] { match self.selection.target { Target::Slides => self.current_slide().map(|s| s.shapes.as_slice()).unwrap_or(&[]), Target::Master { master } => self.doc.masters.get(master).map(|m| m.shapes.as_slice()).unwrap_or(&[]), Target::Layout { master, layout } => { self.doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| l.shapes.as_slice()).unwrap_or(&[]) } } } pub fn shape(&self, id: ShapeId) -> Option<&Shape> { deckcraft_model::find_shape(self.shapes(), id) } pub fn selected_shapes(&self) -> Vec<&Shape> { self.selection.shapes.iter().filter_map(|id| self.shape(*id)).collect() } } /// Mutable access to the edited shape tree of a presentation. pub fn shapes_mut<'a>(doc: &'a mut Presentation, sel: &Selection) -> Option<&'a mut Vec> { match sel.target { Target::Slides => doc.slides.get_mut(sel.slide).map(|s| &mut Arc::make_mut(s).shapes), Target::Master { master } => doc.masters.get_mut(master).map(|m| &mut Arc::make_mut(m).shapes), Target::Layout { master, layout } => { doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)).map(|l| &mut l.shapes) } } } /// Engine preferences (the UI keeps its own). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Prefs { pub snap_to_grid: bool, pub grid_spacing: f64, pub smart_guides: bool, /// Ctrl+D / Paste offset. pub duplicate_offset: f64, pub autocorrect: bool, pub smart_quotes: bool, pub author: String, pub undo_limit: usize, /// Minutes between AutoRecover saves of unsaved presentations. pub recovery_minutes: f64, } impl Default for Prefs { fn default() -> Self { Prefs { snap_to_grid: true, grid_spacing: 6.0, smart_guides: true, duplicate_offset: 12.0, autocorrect: true, smart_quotes: true, author: "Presenter".into(), undo_limit: 500, recovery_minutes: 1.0, } } } /// Things a command or tool asks the UI to do (open a dialog, show a pane, start the show…). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum UiRequest { Dialog { id: String, params: Value, }, Pane { id: String, }, StartShow { from: usize, }, Message { text: String, }, PickFile { purpose: String, }, /// Media playback for the UI host (`media.play` / `pause` / `stop` / `seek`): `action` is /// `play`, `pause`, `toggle`, `stop` or `seek`; `ms` the media time to start from / seek to. Media { action: String, shape: ShapeId, ms: Option, }, } /// Playback state of a media shape as the UI host reports it (see [`Session::media_status`]). #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MediaStatus { /// `loading`, `playing`, `paused` or `ended`. pub state: String, pub position_ms: u64, pub duration_ms: u64, /// Why the media is silent / blank, when it can't be decoded. pub error: Option, } /// Clipboard contents copied within the app. #[derive(Clone, Debug, Default)] pub struct Clipboard { pub shapes: Vec, pub slides: Vec, /// Media the copied shapes/slides reference. pub media: Vec, pub text: Option, pub plain: String, /// How many times the shapes were pasted (each paste offsets further). pub pastes: u32, } pub struct Session { docs: Vec, active: Option, pub prefs: Prefs, pub journal: Vec<(String, Value)>, pub clipboard: Clipboard, pub ui_requests: Vec, pub tool: tools::ToolState, pub(crate) untitled: u32, /// Format Painter: copied shape/text formatting and whether it stays on (double-click). pub painter: Option<(cmd::format::Painted, bool)>, /// Animation Painter: copied shape animations and whether it stays on (double-click). pub anim_painter: Option<(Vec, bool)>, /// Set as Default Shape: the look new shapes get. pub default_look: Option, /// Nesting of commands run by commands: only the outermost one is an undo step and a journal /// entry. depth: u32, /// Crash-recovery folder (the app sets it; saving or closing a presentation removes its entry). pub recovery_dir: Option, /// Media playback state, written by the UI host every frame (empty without a host). pub media_status: std::collections::HashMap, /// Animated GIF pictures paused with `media.gifPlay` (the others play). pub gif_paused: std::collections::HashSet, } impl Default for Session { fn default() -> Self { Self::new() } } impl Session { pub fn new() -> Self { guard::install_panic_hook(); Session { docs: vec![], active: None, prefs: Prefs::default(), journal: vec![], clipboard: Clipboard::default(), ui_requests: vec![], tool: tools::ToolState::default(), untitled: 0, default_look: None, painter: None, anim_painter: None, depth: 0, recovery_dir: None, media_status: Default::default(), gif_paused: Default::default(), } } /// A session with one new presentation open. pub fn with_new() -> Self { let mut s = Self::new(); let _ = s.execute("file.new", &Value::Null); s } pub fn documents(&self) -> &[DocState] { &self.docs } 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) } 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) { if i < self.docs.len() { let d = self.docs.remove(i); if let Some(dir) = &self.recovery_dir { recovery::discard(dir, d.uid); } self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) }; } } pub fn next_untitled(&mut self) -> String { self.untitled += 1; format!("Presentation{}", self.untitled) } /// Run command `id`. A panic inside it becomes [`EngineError::Internal`] and leaves the /// presentation as it was. pub fn execute(&mut self, id: &str, params: &Value) -> Result { self.guarded(id, |s| s.execute_unguarded(id, params)) } fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; if let Err(e) = (spec.enabled)(self) { let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some()); if !named { return Err(EngineError::Disabled(id.into(), e)); } } let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone())); let outer = self.depth == 0; self.depth += 1; let r = (spec.run)(self, params); self.depth -= 1; let r = r?; if let Some(st) = self.active_mut() { clamp_selection(st); } if let (Some((uid, old, sel)), Some(st)) = (before, self.active_mut()) && st.uid == uid && !Arc::ptr_eq(&old, &st.doc) && st.interaction.is_none() && spec.undoable && outer { push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel }); } if spec.journal && outer { self.journal.push((id.to_string(), params.clone())); if self.journal.len() > 10_000 { self.journal.drain(..1000); } } Ok(r) } pub fn commands(&self) -> Vec { command_specs().iter().map(|c| c.info(self)).collect() } /// Mutate the active presentation (copy-on-write) and bump the revision. pub fn edit(&mut self, f: impl FnOnce(&mut Presentation, &mut Selection) -> Result) -> Result { let st = self.doc_mut()?; let mut doc = (*st.doc).clone(); let mut sel = st.selection.clone(); let r = f(&mut doc, &mut sel)?; connect::reroute(&mut doc, &sel); st.doc = Arc::new(doc); st.selection = sel; st.revision += 1; Ok(r) } /// Change only the selection (no undo step). pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> { let st = self.doc_mut()?; let doc = st.doc.clone(); f(&doc, &mut st.selection); clamp_selection(st); st.revision += 1; Ok(()) } } /// Keep the selection pointing at things that exist. fn clamp_selection(st: &mut DocState) { let n = st.doc.slides.len(); if n == 0 { st.selection.slide = 0; } else if st.selection.slide >= n { st.selection.slide = n - 1; } let ids: Vec = st.selection.shapes.iter().copied().filter(|id| st.shape(*id).is_some()).collect(); st.selection.shapes = ids; if let Some(t) = &st.selection.text && !t.notes && st.shape(t.shape).is_none() { st.selection.text = None; } let slides: Vec = st.selection.slides.iter().copied().filter(|id| st.doc.slide(*id).is_some()).collect(); st.selection.slides = slides; match st.selection.target { Target::Master { master } if master >= st.doc.masters.len() => st.selection.target = Target::Slides, Target::Layout { master, layout } if st.doc.masters.get(master).is_none_or(|m| layout >= m.layouts.len()) => { st.selection.target = Target::Slides } _ => {} } } pub(crate) 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); } } #[cfg(test)] mod tests;