//! 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>, redo: Vec>, 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, pub path: Option, pub dirty: bool, pub clipboard: Option, /// 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, /// Page the caret was last on (headers/footers repeat on many pages). pub page_hint: usize, pub registry: Arc, /// 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>, /// Undo steps dropped so far because of the undo limit. undo_evicted: u64, redo: Vec>, /// 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, bool)>, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, /// 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>, 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, 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, /// 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, /// 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, /// 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, /// Column (block) selection, if one was made (see [`Session::column_segments`]). pub column: Option, /// 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, /// 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 { 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 { 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 { 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 { 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 { 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::().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() } }