Files
wordcraft/crates/engine/src/session.rs
T
e71e2d1c52 Draw tab: draw with pen, pencil and highlighter, erase ink, and hide ink (#315)
* Draw Table and Eraser: draw tables and split or merge cells with a pen (#303)

Insert › Tables › Draw Table and Table Layout › Draw › Draw Table / Eraser.
table.draw takes a pen stroke in page points: a rectangle outside a table
inserts a one-cell table of that size at the paragraph under its start; a
straight vertical or horizontal line inside a table splits the cells it
crosses (new grid column or row; untouched cells span it). table.eraser
merges the two cells beside the border nearest a point. Each stroke is one
undo step. In the app the buttons toggle pen and eraser modes with a live
preview, a crosshair with the tool's icon, and Esc to stop; canvas clicks
don't move the caret while a tool is on. Splitting through part of a
vertically merged cell and erasing beside one are refused with a clear
error. Catalog 384/430 -> 387/430.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #315 review fixes: eraser undo test, sanitized tap dots, custGeom path cap, ink docs

- draw.eraser with a point erases through draw.erase: test that it is one
  undo step (undo/redo) and marks the document dirty.
- Render and PDF: a single-point stroke's dot is drawn at the sanitized
  point, not the raw first one.
- DOCX custGeom reading stops once MAX_PATHS paths (subpaths included) are
  collected, across a:path elements.
- docs/file-format-parity.md: ink is saved as custom geometry, not InkML, so
  Word shows it as a freeform.
- widgets test: theme::apply takes an Appearance (#317).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw tab: pen, pencil and highlighter ink, stroke eraser and Hide Ink (#307)

Ink strokes are floating freeform shapes: a new Freeform geometry (paths in
shape-local coordinates, opacity, and the pen that drew it) on
InlineObject::Shape with ShapeKind::Freeform. draw.stroke inserts one
anchored to the paragraph under the first point (one undo step; points
capped per stroke, strokes capped per page, NaN dropped); draw.erase /
draw.eraser with a point deletes the stroke under it; draw.pen, draw.pencil,
draw.highlighter, draw.eraser and draw.select set the Draw tab's tool in the
session view state; review.hideInk toggles showing ink on screen.

The canvas draws a live stroke while a pen is dragged and commits it on
release; the eraser deletes strokes it passes over (one undo step per drag);
Esc or Select goes back to selecting. Draw tab buttons show the tool in use,
with colour and thickness menus; Review gets an Ink group with Hide Ink.
Ink renders with round caps and joins on screen and in PDF; highlighters are
translucent and behind the text. DOCX writes freeforms as wps:wsp with
a:custGeom (moveTo/lnTo) in a wp:anchor and reads them back, along with
simple custom-geometry freeforms from other files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 18:50:37 -04:00

785 lines
30 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 editing session: document, selection, undo/redo, layout, view state.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::props::CharProps;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, ViewMode};
use crate::{CmdError, Registry};
/// A selection: anchor (where it started) and focus (where the caret is).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct Selection {
pub anchor: Pos,
pub focus: Pos,
}
impl Selection {
pub fn caret(p: Pos) -> Self {
Selection { anchor: p.clone(), focus: p }
}
pub fn is_collapsed(&self) -> bool {
self.anchor == self.focus
}
/// (start, end) in document order.
pub fn ordered(&self) -> (Pos, Pos) {
if self.anchor <= self.focus { (self.anchor.clone(), self.focus.clone()) } else { (self.focus.clone(), self.anchor.clone()) }
}
}
/// A column (block) selection: the same x range on every line between two corners.
#[derive(Clone, Debug, PartialEq)]
pub struct ColumnBlock {
/// The stream selection the block was made with (its corners). The block is current only
/// while `Session::sel` still equals it, so any ordinary caret move or selection drops it.
pub sel: Selection,
/// Page x of the anchor and focus corners (points); the block spans the range between.
pub anchor_x: f32,
pub focus_x: f32,
/// One `(start, end)` per line, in document order, each inside one paragraph.
pub segments: Vec<(Pos, Pos)>,
}
/// View state that commands can change (ribbon View tab, status bar).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ViewState {
pub zoom: f32,
pub mode: ViewMode,
pub read_mode: bool,
pub focus_mode: bool,
pub marks: bool,
pub ruler: bool,
/// View › Show › Gridlines: the drawing grid over the page's text area (on screen only).
pub gridlines: bool,
/// Table Layout › View Gridlines: outlines of table cells (on screen only); on by default, as in Word.
#[serde(default = "on")]
pub table_gridlines: bool,
pub nav_pane: bool,
pub styles_pane: bool,
/// Style Inspector pane (Home › Styles).
#[serde(default)]
pub style_inspector: bool,
pub comments_pane: bool,
/// The Clipboard pane (Home › Clipboard): items collected by Copy and Cut.
#[serde(default)]
pub clipboard_pane: bool,
pub multi_page: bool,
/// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty.
pub fit: String,
pub web_width: f32,
pub dark_mode: bool,
pub show_markup: bool,
pub track_changes_pane: bool,
/// Check spelling and grammar as you type.
pub proofing: bool,
/// Review › Hide Ink: ink strokes aren't shown on screen (they stay in the document).
#[serde(default)]
pub hide_ink: bool,
/// Draw tab: the tool dragging on the page uses, and each pen's colour and thickness.
#[serde(default)]
pub draw: crate::cmd::draw::DrawState,
}
fn on() -> bool {
true
}
impl Default for ViewState {
fn default() -> Self {
ViewState {
zoom: 1.0,
mode: ViewMode::Print,
read_mode: false,
focus_mode: false,
marks: false,
ruler: true,
gridlines: false,
table_gridlines: true,
nav_pane: false,
styles_pane: false,
style_inspector: false,
comments_pane: false,
clipboard_pane: false,
multi_page: false,
fit: String::new(),
web_width: 800.0,
dark_mode: false,
show_markup: true,
track_changes_pane: false,
proofing: true,
hide_ink: false,
draw: Default::default(),
}
}
}
/// Find/replace state.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct FindState {
pub query: String,
pub replace: String,
pub match_case: bool,
pub whole_word: bool,
pub regex: bool,
/// Matches from the last search: (start, end).
#[serde(skip)]
pub results: Vec<(Pos, Pos)>,
pub current: usize,
/// Reading Highlight: mark every match of `query` in the body.
pub highlight: bool,
/// Highlighted matches, for the document revision they were found in.
#[serde(skip)]
pub highlights: Option<(u64, Vec<(Pos, Pos)>)>,
}
#[derive(Clone)]
struct Undo {
label: String,
doc: Document,
sel: Selection,
}
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
/// open typing group, a pending [`Session::join_next_undo`] and the dirty flag. See
/// [`Session::edit_snapshot`].
pub struct EditSnapshot {
doc: Document,
sel: Selection,
history_len: usize,
/// `Session::undo_evicted` when the snapshot was taken.
evicted: u64,
/// The oldest undo steps, kept only when the stack is near its limit (the commands that
/// follow may push them out); at most [`SNAPSHOT_HEAD`].
head: Vec<Arc<Undo>>,
redo: Vec<Arc<Undo>>,
typing_open: bool,
join_next: bool,
dirty: bool,
}
/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`]
/// may push out of a full stack and still come back.
const SNAPSHOT_HEAD: usize = 4;
impl EditSnapshot {
/// The document when the snapshot was taken.
pub fn doc(&self) -> &Document {
&self.doc
}
/// The dirty flag then.
pub fn dirty(&self) -> bool {
self.dirty
}
/// The number of undo steps then.
pub fn undo_depth(&self) -> usize {
self.history_len
}
}
/// An editing session.
pub struct Session {
pub doc: Document,
pub sel: Selection,
pub view: ViewState,
/// Formatting picked with a collapsed caret, applied to the next typed text.
pub pending: Option<CharProps>,
pub path: Option<std::path::PathBuf>,
pub dirty: bool,
pub clipboard: Option<Fragment>,
/// Plain text mirror of the clipboard (for the system clipboard).
pub clipboard_text: String,
/// Items collected by Copy and Cut this session, for the Clipboard pane.
pub clip_history: crate::cmd::edit::ClipHistory,
pub find: FindState,
/// Page x the caret tries to keep on Up/Down.
pub goal_x: Option<f32>,
/// Page the caret was last on (headers/footers repeat on many pages).
pub page_hint: usize,
pub registry: Arc<Registry>,
/// Author name for comments and tracked changes.
pub author: String,
/// Format painter: copied formatting waiting to be applied (and whether it stays on).
pub painter: Option<(CharProps, wordcraft_doc::props::ParaProps, bool)>,
/// Last message for the status bar / agents.
pub status: String,
/// Undo and redo steps. Shared so `run` can snapshot both stacks cheaply (a pointer per
/// step) and put them back exactly when a command fails.
history: Vec<Arc<Undo>>,
/// Undo steps dropped so far because of the undo limit.
undo_evicted: u64,
redo: Vec<Arc<Undo>>,
/// Typing is coalesced into one undo step until something else happens.
typing_open: bool,
/// The next mutating command joins the previous undo step (later frames of a drag).
join_next: bool,
rev: u64,
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
/// mail merge, recover), never on an edit.
document_id: u64,
cache: LayoutCache,
layout: Option<(u64, f32, ViewMode, Arc<DocLayout>, bool)>,
/// Picture edits: edited media key → original media key (Reset Picture).
pub originals: std::collections::HashMap<String, String>,
/// Last mutating command (Repeat).
pub last_command: Option<(String, Value)>,
/// Macro being recorded: (name, steps).
pub recording: Option<(String, Vec<(String, Value)>)>,
pub macros: std::collections::BTreeMap<String, Vec<(String, Value)>>,
pub autocorrect_on: bool,
pub autocorrect_user: Vec<(String, String)>,
/// Saved versions: (label, date, document).
pub versions: Vec<(String, String, Document)>,
/// Quick Parts / AutoText entries.
pub building_blocks: std::collections::BTreeMap<String, Fragment>,
pub autosave: bool,
/// Citation style: APA, MLA, Chicago, IEEE.
pub bib_style: String,
/// Mail merge data source and preview.
pub merge: crate::cmd::mailings::MergeState,
/// Requests from commands to the UI (open a dialog, scroll…), drained by the front end.
pub ui_requests: Vec<Value>,
/// Read Aloud player (Review › Speech).
pub read_aloud: crate::speech::ReadAloud,
/// Preferences the front end saves between runs.
pub prefs: Prefs,
/// Editing inside an equation: which one and the caret in it.
pub math: Option<MathEdit>,
/// Equations are typed in LaTeX rather than the linear format.
pub math_latex: bool,
/// Text typed into equations is normal (non-math) text.
pub math_normal_text: bool,
/// The password Word documents are saved with (File › Info › Protect Document › Encrypt with
/// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected
/// file that was opened, cleared when the document is replaced.
pub password: Option<crate::io::Password>,
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
/// for Group. Cleared by any edit or selection change other than adding to it.
pub also_selected: Vec<Pos>,
/// Column (block) selection, if one was made (see [`Session::column_segments`]).
pub column: Option<ColumnBlock>,
/// Column selection mode (Ctrl+Shift+F8): caret movement extends the block.
pub column_mode: bool,
}
/// The equation being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct MathEdit {
/// The equation object (its U+FFFC).
pub at: Pos,
/// The caret inside it.
pub pos: wordcraft_doc::math_edit::MathPos,
}
/// Editing preferences that persist between runs (the front end saves and restores them).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Prefs {
/// Word Count includes text boxes, footnotes and endnotes (Word's default).
pub count_notes: bool,
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
}
impl Default for Prefs {
fn default() -> Self {
Prefs { count_notes: true, markup: Default::default() }
}
}
/// Maximum undo depth.
const MAX_UNDO: usize = 500;
impl Session {
pub fn new(doc: Document) -> Self {
let start = doc.start_of(StoryRef::Body);
Session {
doc,
sel: Selection::caret(start),
view: ViewState::default(),
pending: None,
path: None,
dirty: false,
clipboard: None,
clipboard_text: String::new(),
clip_history: Default::default(),
find: FindState::default(),
goal_x: None,
page_hint: 0,
registry: Arc::new(crate::cmd::registry()),
author: "WordCraft User".into(),
painter: None,
status: String::new(),
history: Vec::new(),
undo_evicted: 0,
redo: Vec::new(),
typing_open: false,
join_next: false,
rev: 1,
document_id: 1,
cache: LayoutCache::new(),
layout: None,
originals: Default::default(),
last_command: None,
recording: None,
macros: Default::default(),
autocorrect_on: true,
autocorrect_user: Vec::new(),
versions: Vec::new(),
building_blocks: Default::default(),
autosave: true,
bib_style: "APA".into(),
merge: Default::default(),
ui_requests: Vec::new(),
math: None,
math_latex: false,
math_normal_text: false,
also_selected: Vec::new(),
prefs: Prefs::default(),
read_aloud: Default::default(),
password: None,
column: None,
column_mode: false,
}
}
/// The line pieces of the current column selection, if there is one (the selection hasn't
/// moved since the block was made).
pub fn column_segments(&self) -> Option<&[(Pos, Pos)]> {
self.column.as_ref().filter(|c| c.sel == self.sel).map(|c| c.segments.as_slice())
}
/// Document revision (bumped by every change).
pub fn rev(&self) -> u64 {
self.rev
}
/// Which document is open (see `document_id`): lets a caller tell an edit from a replacement.
pub fn document_id(&self) -> u64 {
self.document_id
}
pub fn touch(&mut self) {
self.rev = self.rev.wrapping_add(1);
self.dirty = true;
}
/// Reading Highlight ranges for the current document (searched again after edits); empty when it's off.
pub fn find_highlights(&mut self) -> &[(Pos, Pos)] {
if !self.find.highlight {
return &[];
}
if self.find.highlights.as_ref().is_none_or(|(r, _)| *r != self.rev) {
let found = crate::cmd::edit::search(self, StoryRef::Body).unwrap_or_default();
self.find.highlights = Some((self.rev, found));
}
self.find.highlights.as_ref().map(|(_, v)| v.as_slice()).unwrap_or(&[])
}
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let ww = self.view.web_width;
if let Some((r, w, m, l, pf)) = &self.layout
&& *r == self.rev
&& (*w == ww || self.view.mode == ViewMode::Print)
&& *m == self.view.mode
&& *pf == self.view.proofing
{
return l.clone();
}
let opts = LayoutOptions {
view: self.view.mode,
web_width: ww,
show_hidden: self.view.marks,
hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(),
proofing: self.view.proofing,
};
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing));
l
}
/// A layout for output (PDF, images, print): no proofing marks, print view.
pub fn export_layout(&self) -> Arc<DocLayout> {
let opts = LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, hide_deleted: false, proofing: false };
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
}
/// Invalidate the cached layout (fonts changed etc.).
pub fn relayout(&mut self) {
self.layout = None;
self.cache.clear();
}
/// Snapshot for undo before a change.
pub fn checkpoint(&mut self, label: &str) {
if label == "Typing" && self.typing_open {
return;
}
self.typing_open = label == "Typing";
self.history.push(Arc::new(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone() }));
if self.history.len() > MAX_UNDO {
self.history.remove(0);
self.undo_evicted += 1;
}
self.redo.clear();
}
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel }));
if self.history.len() > MAX_UNDO {
self.history.remove(0);
self.undo_evicted += 1;
}
self.typing_open = false;
}
/// Make the next mutating command part of the previous undo step instead of a new one, so a
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
/// except the first; it is consumed by the next `run`.
pub fn join_next_undo(&mut self) {
self.join_next = true;
}
/// Stamp the document's save metadata (modified time, last modified by, revision; created
/// when it has none) the way Word does on every save. `file.save` calls it, and so does any
/// front end that writes the bytes itself (the web build's download, #262).
pub fn stamp_save(&mut self) {
let core = &mut self.doc.core;
core.modified = crate::cmd::now_iso();
if core.created.is_empty() {
core.created = core.modified.clone();
}
core.last_modified_by = self.author.clone();
core.revision = core.revision.saturating_add(1);
}
/// Close an open typing group (caret moved, other command).
pub fn close_typing(&mut self) {
self.typing_open = false;
}
pub fn can_undo(&self) -> bool {
!self.history.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn undo_label(&self) -> Option<&str> {
self.history.last().map(|u| u.label.as_str())
}
pub fn redo_label(&self) -> Option<&str> {
self.redo.last().map(|u| u.label.as_str())
}
/// The number of undo steps.
pub fn undo_depth(&self) -> usize {
self.history.len()
}
pub fn undo_labels(&self) -> Vec<String> {
self.history.iter().rev().map(|u| u.label.clone()).collect()
}
pub fn undo(&mut self) -> bool {
self.typing_open = false;
let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false };
let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) };
self.redo.push(Arc::new(cur));
self.touch();
true
}
pub fn redo(&mut self) -> bool {
self.typing_open = false;
let Some(u) = self.redo.pop().map(Arc::unwrap_or_clone) else { return false };
let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) };
self.history.push(Arc::new(cur));
self.touch();
true
}
/// Drop history (after open / new).
pub fn reset_history(&mut self) {
self.history.clear();
self.redo.clear();
self.typing_open = false;
}
/// Replace the document (open/new).
pub fn set_document(&mut self, doc: Document) {
self.document_id = self.document_id.wrapping_add(1);
self.doc = doc;
self.doc.ensure_nonempty();
self.password = None;
self.sel = Selection::caret(self.doc.start_of(StoryRef::Body));
self.pending = None;
self.also_selected.clear();
self.column = None;
self.column_mode = false;
self.reset_history();
self.touch();
self.dirty = false;
self.relayout();
}
/// A snapshot to put back with [`Session::restore`], for a caller that runs a command and
/// may want to take it back without a trace (cheap: the document's blocks are shared).
///
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
/// the limit), `redo`, the open typing group, a pending `join_next_undo` and `dirty`. `rev`,
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
/// covered: a caller that lets the command change them (the view, the find state, the
/// clipboard, macros, the file path…) saves and puts them back itself.
///
/// `restore` is exact when the commands in between only add undo steps (every mutating
/// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full
/// stack). Taking steps off the stack (`edit.undo`) or clearing it (`file.new`,
/// `file.open`) in between is not covered.
///
/// The destructuring lists every field, so a new field does not build until someone decides
/// whether a snapshot must cover it.
pub fn edit_snapshot(&self) -> EditSnapshot {
let Session {
doc,
sel,
view: _,
pending: _,
path: _,
dirty,
clipboard: _,
clipboard_text: _,
clip_history: _,
find: _,
goal_x: _,
page_hint: _,
registry: _,
author: _,
painter: _,
status: _,
history,
undo_evicted,
redo,
typing_open,
join_next,
rev: _,
cache: _,
layout: _,
originals: _,
last_command: _,
recording: _,
macros: _,
autocorrect_on: _,
autocorrect_user: _,
versions: _,
building_blocks: _,
autosave: _,
bib_style: _,
merge: _,
ui_requests: _,
document_id: _,
read_aloud: _,
prefs: _,
// Equation editing mode, like `view`: not part of the document or its history.
math: _,
math_latex: _,
math_normal_text: _,
// The save password, like the file path: not an edit to the document.
password: _,
also_selected: _,
// Column selection, like `sel`'s shape: valid only while `sel` matches it.
column: _,
column_mode: _,
} = self;
let head = if history.len() + SNAPSHOT_HEAD > MAX_UNDO { history.iter().take(SNAPSHOT_HEAD).cloned().collect() } else { Vec::new() };
EditSnapshot {
doc: doc.clone(),
sel: sel.clone(),
history_len: history.len(),
evicted: *undo_evicted,
head,
redo: redo.clone(),
typing_open: *typing_open,
join_next: *join_next,
dirty: *dirty,
}
}
/// Put a snapshot back: the commands run since leave no trace in the document, the
/// selection or undo/redo (undo steps the limit pushed out meanwhile come back). The layout
/// is recomputed.
pub fn restore(&mut self, snap: EditSnapshot) {
let EditSnapshot { doc, sel, history_len, evicted, head, redo, typing_open, join_next, dirty } = snap;
let gone = usize::try_from(self.undo_evicted.saturating_sub(evicted)).unwrap_or(usize::MAX);
if gone > 0 {
let back = gone.min(head.len());
self.history.splice(0..0, head.into_iter().take(back));
}
self.undo_evicted = evicted;
self.doc = doc;
self.sel = sel;
self.also_selected.clear();
self.history.truncate(history_len);
self.redo = redo;
self.typing_open = typing_open;
self.join_next = join_next;
self.touch();
self.dirty = dirty;
}
/// Make the selection valid for the current document.
pub fn clamp_selection(&mut self) {
self.sel.anchor = self.doc.clamp(&self.sel.anchor);
self.sel.focus = self.doc.clamp(&self.sel.focus);
// The equation being edited must still be there (undo, edits elsewhere).
if let Some(m) = &self.math {
let eq = self.doc.para_at(&m.at).and_then(|p| match p.object_at(m.at.off) {
Some(wordcraft_doc::InlineObject::Equation { math, .. }) => Some(wordcraft_doc::math_edit::clamp(&math.nodes, &m.pos)),
_ => None,
});
match eq {
Some(pos) => {
if let Some(m) = self.math.as_mut() {
m.pos = pos;
}
}
None => self.math = None,
}
}
}
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
/// control channel). Mutating commands are undoable; a failed command leaves the document as
/// it was; a panic inside a command becomes an error.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
let join = std::mem::take(&mut self.join_next);
let reg = self.registry.clone();
let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) };
if let Some(why) = (spec.enabled)(self) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
}
// Restrict Editing.
if spec.mutates
&& let Some(mode) = self.doc.settings.protection.clone()
{
let allowed = id.starts_with("review.restrict") || id.starts_with("file.") || id == "edit.undo" || id == "edit.redo";
let comment_ok = id.starts_with("review.") && (id.contains("Comment") || id == "review.reply");
match mode.as_str() {
"readOnly" | "forms" if !allowed => return Err(CmdError::Disabled(format!("{id}: the document is protected (read only)"))),
"comments" if !allowed && !comment_ok => return Err(CmdError::Disabled(format!("{id}: only comments are allowed in this document"))),
"trackedChanges" => self.doc.settings.track_changes = true,
_ => {}
}
}
// Macro recording and Repeat.
let record = !matches!(id, "tools.recordMacro" | "tools.macros" | "edit.undo" | "edit.redo" | "edit.repeat")
&& (spec.mutates || id.starts_with("caret.") || id.starts_with("select."));
if record && let Some((_, steps)) = self.recording.as_mut() {
steps.push((id.to_string(), params.clone()));
}
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") {
self.last_command = Some((id.to_string(), params.clone()));
}
// Typing, caret movement and selection outside the equation commands leave the equation.
if self.math.is_some()
&& (id.starts_with("text.") || id.starts_with("caret.") || id.starts_with("select.") || id.starts_with("edit.paste"))
&& let Some(m) = self.math.take()
{
let off = m.at.off + wordcraft_doc::para::OBJ.len_utf8();
self.sel = Selection::caret(Pos { off, ..m.at });
}
// The undo stacks are snapshotted whole, not as a length: commands run nested commands,
// whose checkpoints, undos and redos (and evictions at the limit) must be put back too.
let before_doc = if spec.mutates {
Some((
self.doc.clone(),
self.sel.clone(),
self.history.clone(),
self.redo.clone(),
self.typing_open,
self.undo_evicted,
self.column.clone(),
))
} else {
None
};
let typing = spec.id == "text.insert" || spec.id == "equation.type";
if spec.mutates && !typing {
self.typing_open = false;
}
if spec.mutates {
let label = if typing { "Typing" } else { spec.label };
if !join || self.history.is_empty() {
self.checkpoint(label);
}
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
// Non-mutating commands other than caret movement keep the typing group.
} else if spec.id.starts_with("caret.") {
self.typing_open = false;
}
let sel_before = self.sel.clone();
let run = spec.run;
let mutates = spec.mutates;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params)));
let result = match result {
Ok(r) => r,
Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
}
};
match &result {
Ok(_) => {
if spec.mutates {
self.touch();
self.doc.ensure_nonempty();
self.doc.prune_text_boxes();
}
self.clamp_selection();
// Extra selected objects last until something else is edited or selected.
if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) {
self.also_selected.clear();
}
}
Err(e) => {
if let Some((d, s, h, r, t, ev, c)) = before_doc {
self.doc = d;
self.sel = s;
self.history = h;
self.redo = r;
self.typing_open = t;
// The steps the command pushed out are back, so they no longer count as gone.
self.undo_evicted = ev;
self.column = c;
}
self.status = e.to_string();
}
}
result
}
/// The paragraph path the caret is in.
pub fn caret_path(&self) -> &Path {
&self.sel.focus.path
}
pub fn story(&self) -> StoryRef {
self.sel.focus.story
}
/// Formatting typing would use at the caret.
pub fn typing_props(&self) -> CharProps {
if let Some(p) = &self.pending {
return p.clone();
}
let f = &self.sel.focus;
self.doc.para_at(f).map(|p| p.props_at(f.off).clone()).unwrap_or_default()
}
/// Words in the document, as the status bar shows them (see [`Prefs::count_notes`]).
pub fn word_count(&self) -> usize {
if self.prefs.count_notes { self.doc.word_count_including_notes() } else { self.doc.word_count() }
}
/// Selected plain text.
pub fn selected_text(&self) -> String {
let (a, b) = self.sel.ordered();
self.doc.copy_range(&a, &b).plain_text()
}
}