Files
wordcraft/crates/engine/src/session.rs
T
34511263a7 Customize Ribbon and Quick Access Toolbar (#379) (#384)
* Customize Keyboard dialog (#368)

Tools > Customize Keyboard (tools.customizeKeyboard): pick a category and
a command, see its keys, press a combination to see which command uses it,
then Assign / Remove / Reset All. Also reachable from File > Options
(Keyboard shortcuts: Customize...) and command search.

- engine: KeyMap (assigned key -> command, removed built-in keys), consulted
  before the registry's built-in shortcuts; keys compared in one canonical
  form. Saved maps read leniently: junk entries and unknown commands are
  dropped, never failing the rest of the preferences.
- tools.customizeKeyboard: with params (assign/remove/reset/command/key) it
  only changes or reports the map; without params it opens the dialog.
- ui: key dispatch and menu/tooltip shortcut text use the user map; the map
  persists in ui.json; more keys (Q, F1-F12, 4/6/7, Insert) can be bound.
- i18n strings in every catalog; docs/ui-parity, gaps #24, ROADMAP log.

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

* Customize Ribbon and Quick Access Toolbar (#379)

Tools > Customize Ribbon (tools.customizeRibbon): one dialog with two
pages. Ribbon: commands by category next to the ribbon's tabs; show or
hide and reorder tabs, add custom tabs and custom groups (on built-in or
custom tabs) holding any commands, rename, remove, reset. Quick Access
Toolbar: add, remove and reorder its commands, reset.

- engine: RibbonLayout (tab order and visibility, custom groups, the
  toolbar's commands). Saved layouts read leniently: junk, duplicates,
  reserved names and unknown commands are dropped.
- tools.customizeRibbon: with params (hide/show, newTab, newGroup, add,
  remove, rename, move, qatAdd/qatRemove/qatMove, reset, layout) it only
  changes or reports the layout; without params it opens the dialog.
- ui: the tab strip and ribbon follow the layout (custom groups draw the
  commands' own icons, else a lettered tile); the Quick Access Toolbar
  shows the user's commands; right-click any ribbon button to add it to
  (or remove it from) the toolbar or open the dialog; the toolbar's ...
  button opens a menu of common commands and More Commands... (#376);
  File > Options has a second Customize button; layout persists in
  ui.json under `ribbon`.
- Open and Print icons drawn in code; i18n strings in every catalog;
  docs/ui-parity, gaps #24, ROADMAP log, parity checklist.

Fixes #376.

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-11 12:06:17 -04:00

858 lines
33 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,
/// Show field codes instead of their results (`view.fieldCodes`, Alt+F9).
#[serde(default)]
pub field_codes: bool,
}
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(),
field_codes: false,
}
}
}
/// 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,
/// Custom keyboard shortcuts (`tools.customizeKeyboard`); the front end saves them too.
pub keymap: crate::KeyMap,
/// Custom ribbon and Quick Access Toolbar (`tools.customizeRibbon`); the front end saves it too.
pub ribbon: crate::RibbonLayout,
/// 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,
/// Where the last edits happened, oldest first (at most [`EDIT_MARKS`]), for Go Back
/// (Shift+F5). Positions aren't mapped through later edits; they're clamped when used.
edit_marks: Vec<Pos>,
/// How many Go Back presses since the last edit (which place comes next).
go_back_step: usize,
}
/// The number of edit locations Go Back cycles through (Word: four).
const EDIT_MARKS: usize = 4;
/// 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(),
keymap: crate::KeyMap::default(),
ribbon: crate::RibbonLayout::default(),
read_aloud: Default::default(),
password: None,
column: None,
column_mode: false,
edit_marks: Vec::new(),
go_back_step: 0,
}
}
/// Remember the caret as the place of an edit, for Go Back. Edits in the same paragraph as
/// the last one (typing on) update that place rather than adding one.
fn note_edit(&mut self) {
let at = self.sel.focus.clone();
match self.edit_marks.last_mut() {
Some(last) if last.story == at.story && last.path == at.path => *last = at,
_ => {
self.edit_marks.push(at);
if self.edit_marks.len() > EDIT_MARKS {
self.edit_marks.remove(0);
}
}
}
self.go_back_step = 0;
}
/// Go Back (Shift+F5): the next of the last edit places, most recent first, cycling; a place
/// the caret is already at is skipped. `None` before any edit.
pub fn go_back(&mut self) -> Option<Pos> {
let n = self.edit_marks.len();
for _ in 0..n {
let i = n - 1 - self.go_back_step % n;
self.go_back_step = self.go_back_step.wrapping_add(1);
let Some(p) = self.edit_marks.get(i) else { continue };
let p = self.doc.clamp(p);
if p != self.sel.focus || n == 1 {
return Some(p);
}
}
self.edit_marks.last().map(|p| self.doc.clamp(p))
}
/// 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,
field_codes: self.view.field_codes,
};
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, field_codes: 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.edit_marks.clear();
self.go_back_step = 0;
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: _,
keymap: _,
ribbon: _,
// 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: _,
// Go Back's places, like the caret history: not part of the document.
edit_marks: _,
go_back_step: _,
} = 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 {
// Formatting changed while tracking becomes a formatting revision.
if let Some((before, ..)) = &before_doc {
crate::cmd::fmt_revisions::record(self, id, before);
}
self.touch();
self.doc.ensure_nonempty();
self.doc.prune_text_boxes();
}
self.clamp_selection();
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
self.note_edit();
}
// 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;
let Some(p) = self.doc.para_at(f) else { return CharProps::default() };
let props = p.props_at(f.off);
// As in Word, text typed at either end of a hyperlink isn't part of it; only typing
// inside the link extends it.
let inside_link = props.link.is_some() && f.off > 0 && f.off < p.len() && p.props_of_char(f.off).link == props.link;
if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }
}
/// 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()
}
}