499 lines
16 KiB
Rust
499 lines
16 KiB
Rust
//! 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<deckcraft_model::ModelError> for EngineError {
|
|
fn from(e: deckcraft_model::ModelError) -> Self {
|
|
EngineError::Other(e.to_string())
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, EngineError>;
|
|
|
|
/// 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<SlideId>,
|
|
/// Selected shapes on the current slide (top-level or inside groups).
|
|
pub shapes: Vec<ShapeId>,
|
|
pub text: Option<TextSel>,
|
|
/// 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<Presentation>,
|
|
pub selection: Selection,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct History {
|
|
pub undo: Vec<HistoryEntry>,
|
|
pub redo: Vec<HistoryEntry>,
|
|
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<Presentation>,
|
|
pub selection: Selection,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DocState {
|
|
pub doc: Arc<Presentation>,
|
|
pub selection: Selection,
|
|
pub history: History,
|
|
pub path: Option<String>,
|
|
pub revision: u64,
|
|
pub saved_doc: Arc<Presentation>,
|
|
pub interaction: Option<Interaction>,
|
|
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<String>, 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<Shape>> {
|
|
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<u64>,
|
|
},
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// Clipboard contents copied within the app.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Clipboard {
|
|
pub shapes: Vec<Shape>,
|
|
pub slides: Vec<Slide>,
|
|
/// Media the copied shapes/slides reference.
|
|
pub media: Vec<deckcraft_model::MediaItem>,
|
|
pub text: Option<deckcraft_model::TextBody>,
|
|
pub plain: String,
|
|
/// How many times the shapes were pasted (each paste offsets further).
|
|
pub pastes: u32,
|
|
}
|
|
|
|
pub struct Session {
|
|
docs: Vec<DocState>,
|
|
active: Option<usize>,
|
|
pub prefs: Prefs,
|
|
pub journal: Vec<(String, Value)>,
|
|
pub clipboard: Clipboard,
|
|
pub ui_requests: Vec<UiRequest>,
|
|
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<deckcraft_model::anim::Animation>, bool)>,
|
|
/// Set as Default Shape: the look new shapes get.
|
|
pub default_look: Option<cmd::format::Painted>,
|
|
/// 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<std::path::PathBuf>,
|
|
/// Media playback state, written by the UI host every frame (empty without a host).
|
|
pub media_status: std::collections::HashMap<ShapeId, MediaStatus>,
|
|
/// Animated GIF pictures paused with `media.gifPlay` (the others play).
|
|
pub gif_paused: std::collections::HashSet<ShapeId>,
|
|
}
|
|
|
|
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<usize> {
|
|
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<Value> {
|
|
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()))?;
|
|
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<CommandInfo> {
|
|
command_specs().iter().map(|c| c.info(self)).collect()
|
|
}
|
|
|
|
/// Mutate the active presentation (copy-on-write) and bump the revision.
|
|
pub fn edit<T>(&mut self, f: impl FnOnce(&mut Presentation, &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)?;
|
|
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<ShapeId> = 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<SlideId> = 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;
|