diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 953eaf53c..51e42bd6f 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -2,20 +2,6 @@ use std::sync::{Arc, OnceLock}; -/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. -static SYSTEM_LOCALE: OnceLock = OnceLock::new(); - -/// Tell the engine the system locale (the shell calls this once at startup; layout never reads -/// the environment). Headless sessions without it show Western digits for Numeral › System. -pub fn set_system_locale(tag: &str) { - let _ = SYSTEM_LOCALE.set(tag.to_string()); -} - -/// The system locale [`set_system_locale`] recorded, if the shell set one. -pub(crate) fn system_locale() -> Option<&'static str> { - SYSTEM_LOCALE.get().map(String::as_str) -} - use serde::{Deserialize, Serialize}; use serde_json::Value; use wordcraft_doc::edit::Fragment; @@ -31,417 +17,31 @@ pub use documents::{DocInfo, DocState}; use documents::{document_id_mark, next_document_id, untitled_name}; pub use views::WindowView; -/// 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, -} +mod doc_state; +mod history; +mod ime; +mod ime_state; +mod layout; +mod lifecycle; +mod locale; +mod prefs; +mod query; +mod run; +mod snapshot; +mod snapshot_types; +mod view; -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()) } - } -} - -/// The word being typed, left without a spelling squiggle until the caret leaves it (#1552). -#[derive(Clone, Debug, PartialEq, Eq)] -struct TypedWord { - /// Where the caret was when the word was typed. - at: Pos, - /// The word's byte range in its paragraph. - start: usize, - end: usize, -} - -/// 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)>, - /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in - /// a table's selection areas): one segment per paragraph of the cells. - pub cells: Option, -} - -/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into -/// grid columns `cols.0..=cols.1`. -#[derive(Clone, Debug, PartialEq)] -pub struct CellRange { - pub story: StoryRef, - pub table: Path, - pub rows: (usize, usize), - pub cols: (usize, usize), - /// The cells are one run of the text (cells of one row, or whole rows), so the stream - /// selection between the corners holds just them and commands act on that as usual. - /// Otherwise (part of a column) text edits and formatting go cell by cell. - pub stream: bool, -} - -impl CellRange { - /// The selected cells of table `t` (row, cell index), in document order. - pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> { - let ((r0, r1), (g0, g1)) = (self.rows, self.cols); - let mut out = Vec::new(); - for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { - let mut g = row.first_grid(); - for (c, cell) in row.cells.iter().enumerate() { - let end = g.saturating_add(cell.span()); - if g <= g1 && end > g0 { - out.push((r, c)); - } - g = end; - } - } - out - } -} - -/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), -/// and where it really runs. -#[derive(Clone, Debug, PartialEq)] -pub(crate) struct Widened { - /// The selection it became; this is current while `Session::sel` still equals it. - pub sel: Selection, - /// Where the selection was really extended from and to: extending it again goes on from here. - pub ends: Selection, -} - -/// 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, - /// View › Focus (#1678): the document alone on screen, with a slim bar to drop the ribbon down. - pub focus_mode: bool, - /// Full Screen View (#1678): the pages alone on screen. Never on together with `focus_mode`. - #[serde(default)] - pub full_screen_view: 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, - /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). - /// Kept in the user's preferences, not the document. - #[serde(default)] - pub styles_pane_dock: StylesPaneDock, - /// 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, - /// SmartArt Design › Text Pane: the selected SmartArt graphic's items as an editable outline. - #[serde(default)] - pub smart_art_pane: bool, - /// The Accessibility pane (Review › Check Accessibility): the checker's findings. - #[serde(default)] - pub accessibility_pane: bool, - /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. - #[serde(default)] - pub alt_text_pane: bool, - /// The Format Shape pane (Shape Fill › More Gradients…, a shape's Format Shape…): the - /// selected shapes' fill. - #[serde(default)] - pub format_shape_pane: bool, - pub multi_page: bool, - /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. - #[serde(default)] - pub single_column: bool, - /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. - pub fit: String, - /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. - /// Draft and Outline don't use it: they keep a fixed 800 pt column. - #[serde(skip, default = "default_web_width")] - pub web_width: f32, - /// Transient viewport metrics for reading pagination; never saved in a document or preferences. - #[serde(skip, default = "default_read_width")] - pub read_width: f32, - #[serde(skip, default = "default_read_height")] - pub read_height: f32, - /// Reader-only layout controls; they are neither document formatting nor saved UI prefs. - /// `None` chooses responsive column width and keeps the document's line spacing. - #[serde(skip)] - pub read_column_width: Option, - #[serde(skip, default = "default_read_zoom")] - pub read_zoom: f32, - #[serde(skip)] - pub read_line_spacing: Option, - /// Read Mode's Page Color: the paper the reading columns are drawn on. - #[serde(skip)] - pub read_page_color: crate::cmd::view::ReadPageColor, - pub dark_mode: bool, - pub show_markup: bool, - pub track_changes_pane: bool, - /// Check spelling and grammar as you type. - pub proofing: bool, - /// How digits look on screen (Word › File › Options › Advanced › Numeral). - #[serde(default)] - pub numeral: NumeralMode, - /// 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, - /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with - /// headers and footers) between pages collapse to a thin line. On screen only; the layout - /// and page count are unchanged. - #[serde(default)] - pub hide_white_space: bool, - /// Outline view: collapsed headings, Show Level, first lines, text formatting. - #[serde(default)] - pub outline: crate::cmd::outline::OutlineState, - /// Developer › Design Mode: every content control shows its frame and title. - #[serde(default)] - pub design_mode: bool, - /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end - /// carries it to the window. - #[serde(default)] - pub full_screen: bool, -} - -/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum StylesPaneDock { - #[default] - Right, - Left, - /// A window of its own over the document, which can be moved and resized. - #[serde(rename = "float")] - Floating, -} - -impl StylesPaneDock { - /// `left`, `right` or `float`; anything else is `None`. - pub fn parse(name: &str) -> Option { - match name { - "right" => Some(Self::Right), - "left" => Some(Self::Left), - "float" => Some(Self::Floating), - _ => None, - } - } - - pub fn name(self) -> &'static str { - match self { - Self::Right => "right", - Self::Left => "left", - Self::Floating => "float", - } - } -} - -fn on() -> bool { - true -} - -/// The text column of Draft and Outline, and of Web Layout in a headless session. -const DRAFT_WIDTH: f32 = 800.0; - -fn default_web_width() -> f32 { - DRAFT_WIDTH -} - -fn default_read_width() -> f32 { - 300.0 -} - -fn default_read_height() -> f32 { - 500.0 -} - -fn default_read_zoom() -> f32 { - 1.0 -} - -impl Default for ViewState { - fn default() -> Self { - ViewState { - zoom: 1.0, - mode: ViewMode::Print, - read_mode: false, - focus_mode: false, - full_screen_view: false, - marks: false, - ruler: true, - gridlines: false, - table_gridlines: true, - nav_pane: false, - styles_pane: false, - styles_pane_dock: StylesPaneDock::Right, - style_inspector: false, - comments_pane: false, - clipboard_pane: false, - smart_art_pane: false, - accessibility_pane: false, - alt_text_pane: false, - format_shape_pane: false, - multi_page: false, - single_column: false, - fit: String::new(), - web_width: default_web_width(), - read_width: default_read_width(), - read_height: default_read_height(), - read_column_width: None, - read_zoom: default_read_zoom(), - read_line_spacing: None, - read_page_color: Default::default(), - dark_mode: false, - show_markup: true, - track_changes_pane: false, - proofing: true, - numeral: NumeralMode::default(), - hide_ink: false, - draw: Default::default(), - field_codes: false, - hide_white_space: false, - outline: Default::default(), - design_mode: false, - full_screen: 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, - /// Word's "Use wildcards" (#543). - pub wildcards: bool, - /// Take Find what literally: no `^` codes (`special: false`). - pub literal: bool, - pub match_prefix: bool, - pub match_suffix: bool, - pub ignore_punct: bool, - pub ignore_space: 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, - /// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too. - pending: Option, - /// The column block or whole cells the selection was (undoing brings the selection back as - /// it was). - column: Option, -} - -/// 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, the pending -/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises, -/// whether the group has a step of its own, 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, - equation_join_next: bool, - /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. - ime_text_join: Option, - join_anchor: bool, - dirty: bool, - /// The body's replacement epoch then: a previewed replacement that is rolled back is not a - /// replacement after all. - ime_body_epoch: u64, -} - -/// 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 - } -} - -/// What `run` keeps to take a failed mutating command back: the document, the selection, the -/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column -/// block. -type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); - -/// A layout made and what it was made for: (revision, width, read height, view, read line -/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body -/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field -/// codes shown)). -type LayoutEntry = ( - u64, - f32, - f32, - ViewMode, - Option, - Arc, - bool, - Option, - (bool, wordcraft_layout::HideDeleted, bool), - NumeralMode, - Option, - (bool, bool), -); - -/// The layouts last made, newest first. Two, so two windows on one document with different -/// views ([`WindowView`]) each find theirs as they draw in turn. -type LayoutSlots = [Option; 2]; +pub(crate) use doc_state::Widened; +pub use doc_state::{CellRange, ColumnBlock, FindState, MathEdit, Selection, UNDO_LIST_MAX, next_math_session}; +use doc_state::{TypedWord, Undo, doc_bib_style}; +use ime_state::ImeCaretState; +pub use locale::set_system_locale; +pub(crate) use locale::system_locale; +pub use prefs::Prefs; +pub use query::blank_document; +pub use snapshot_types::EditSnapshot; +use snapshot_types::{LayoutSlots, RunSnapshot}; +pub use view::{StylesPaneDock, ViewState}; /// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) /// and the open documents. The active document's state is in the session's own fields, which @@ -638,1895 +238,7 @@ pub struct Session { untitled_count: u32, } -/// The number of edit locations Go Back cycles through (Word: four). -const EDIT_MARKS: usize = 4; - -/// The caret identity used for the process-monotonic IME state revision. -#[derive(Clone, Debug, PartialEq, Eq)] -enum ImeCaretState { - Body(Selection), - Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, -} - -/// One checked allocation from a process-monotonic IME revision counter. -fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { - counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() -} - -static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is -/// exhausted. -fn next_ime_state_revision() -> Option { - allocate_ime_state_revision(&IME_STATE_REVISION) -} - -/// The equation being edited. -#[derive(Clone, Debug, PartialEq)] -pub struct MathEdit { - /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an - /// IME owner; [`Self::session`] is. - pub at: Pos, - /// The caret inside it. - pub pos: wordcraft_doc::math_edit::MathPos, - /// A stable token for this editing session, unique across documents and re-entry. - pub session: u64, -} - -/// Next stable token for an equation editing session, or `None` when the counter is exhausted. -/// -/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the -/// equation it described, so the caller refuses to start editing instead. -pub fn next_math_session() -> Option { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() -} - -/// Editing preferences that persist between runs (the front end saves and restores them). -/// File › Options sets most of them (`file.options`). -#[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, - /// File › Print / Export: Print Markup. PDFs, prints and page images show comments in - /// balloons beside each page and tracked changes as marked up; HTML, Markdown and text - /// copies end with the comments (`file.printMarkup`). - pub print_markup: bool, - /// Language › Detect language automatically (per user, as in Word). - pub detect_language: bool, - /// Set Proofing Language › Set As Default: overrides the interface language for future - /// blank documents and plain text. Older preferences keep following the interface. - #[serde(deserialize_with = "lenient_default_language")] - pub default_language: Option, - /// Typing over a selection replaces it; off, the typed text goes in front of it. - pub typing_replaces_selection: bool, - /// The Insert key switches overtype on and off. - pub insert_key_overtype: bool, - /// Overtype: typed characters replace the ones after the caret. - pub overtype: bool, - /// The unit rulers and dialogs show lengths in, when the user chose one (File › Options › - /// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under - /// a new key; an older `units` value other than inches (the old default) carries over on load. - #[serde(rename = "measurementUnits")] - pub units: Option, - /// The region the user chose for new documents, units and the decimal separator (a locale - /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. - pub region: Option, - /// A folder searched for Hunspell dictionaries before the system locations (File › Options › - /// Proofing, #1063): an app started from Finder or the Dock doesn't see - /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. - pub dictionaries_folder: Option, - /// The user templates folder File › New lists personal templates from and Save as Word - /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's - /// own `Templates` folder in its settings folder. - pub user_templates_folder: Option, - /// Formatting marks shown even while Show/Hide ¶ is off. - pub marks: wordcraft_layout::display::FormattingMarks, - /// Mark grammar errors as you type (with Check spelling as you type on). - pub mark_grammar: bool, - /// Check spelling as you type (File › Options › Proofing). The live value is - /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). - pub check_spelling: bool, - pub ignore_uppercase: bool, - pub ignore_numbers: bool, - pub ignore_internet: bool, - /// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as - /// in Word). A damaged saved value reads as the defaults. - #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] - pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, -} - -impl Default for Prefs { - fn default() -> Self { - Prefs { - count_notes: true, - markup: Default::default(), - print_markup: false, - typing_replaces_selection: true, - insert_key_overtype: false, - overtype: false, - units: Default::default(), - region: None, - dictionaries_folder: None, - user_templates_folder: None, - marks: Default::default(), - mark_grammar: true, - check_spelling: true, - ignore_uppercase: true, - ignore_numbers: true, - ignore_internet: true, - detect_language: true, - default_language: None, - autocorrect: Default::default(), - } - } -} - -impl Prefs { - /// What the spelling check skips. - pub fn spelling(&self) -> wordcraft_proof::Options { - wordcraft_proof::Options { - ignore_uppercase: self.ignore_uppercase, - ignore_numbers: self.ignore_numbers, - ignore_internet: self.ignore_internet, - } - } -} - -/// Ignore malformed saved language tags without discarding the other preferences. -fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { - let value = serde_json::Value::deserialize(d)?; - Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag)) -} - -/// The citation style a document records (References › Style), APA when it records none or -/// one we don't know. -/// The byte range of the word the caret at `off` ends or sits in, counting apostrophes and -/// hyphens as the spelling check does; `None` unless a letter or digit comes before the caret. -fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { - let in_word = |c: char| wordcraft_doc::bidi::is_word_char(c) || matches!(c, '’' | '-'); - let before = text.get(..off)?; - let start = before.char_indices().rev().take_while(|&(_, c)| in_word(c)).last().map(|(i, _)| i)?; - let end = off.saturating_add(text.get(off..)?.chars().take_while(|&c| in_word(c)).map(char::len_utf8).sum::()); - text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) -} - -/// The name of `doc`'s bibliography style, APA when it names none we know. -fn doc_bib_style(doc: &Document) -> String { - doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() -} - -/// Most pending front-end requests a session keeps. -pub const MAX_UI_REQUESTS: usize = 64; -/// Maximum undo depth. -const MAX_UNDO: usize = 500; -/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. -pub const UNDO_LIST_MAX: usize = 100; - impl Session { - /// Queue a request for the front end. A headless session never drains the queue, so only - /// the newest [`MAX_UI_REQUESTS`] are kept. - pub fn push_ui_request(&mut self, req: Value) { - if self.ui_requests.len() >= MAX_UI_REQUESTS { - self.ui_requests.remove(0); - } - self.ui_requests.push(req); - } - /// File name of the persisted custom dictionary inside the settings folder. pub const USER_DICT_FILE: &str = "custom-dictionary.txt"; - - /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, - /// with `removed` (words just taken out) left out of the merge with the file. A failed save - /// keeps the words in memory and never fails the command (#40), as does a file outside the - /// automation roots in force: it is read, then replaced beside itself (#3737). - pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { - let Some(path) = self.user_dict_path.as_deref() else { return }; - let name = path.to_string_lossy(); - if let Err(e) = crate::file_access::check_read(&name) - .and_then(|()| crate::file_access::check_write(&name)) - .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) - { - log::warn!("custom dictionary: save failed: {e}"); - } - } - - /// Start an editing session at the document beginning with empty history and - /// default view, preferences and a yellow highlighter independent of document undo. - pub fn new(doc: Document) -> Self { - Self::with_registry(doc, Arc::new(crate::cmd::registry())) - } - - /// Initialise session defaults while sharing the immutable command registry with previews. - fn with_registry(doc: Document, registry: Arc) -> Self { - let DocState { - doc, - sel, - view, - pending, - path, - file_name, - dirty, - pending_move, - find, - goal_x, - page_hint, - new_header_parts, - hf_return, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - ime_body_epoch, - cache, - layout, - typing, - originals, - picture_looks, - versions, - autosave, - bib_style, - merge, - math, - password, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - also_selected, - column, - column_mode, - widened, - edit_marks, - go_back_step, - } = DocState::new(doc, 1, ViewState::default()); - Session { - doc, - sel, - view, - pending, - last_highlight: wordcraft_doc::props::Highlight::Yellow, - path, - file_name, - user_dict_path: None, - dirty, - clipboard: None, - pending_move, - clipboard_text: String::new(), - clipboard_html: String::new(), - clip_history: Default::default(), - find, - goal_x, - page_hint, - registry, - author: "WordCraft User".into(), - initials: String::new(), - painter: None, - status: String::new(), - new_header_parts, - hf_return, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap, - rev, - document_id, - untitled, - made_rev, - protect_facts: std::sync::Mutex::new(None), - ime_body_epoch, - ime_state_revision: None, - ime_state_seen: None, - ime_edit_outcome: ImeEditOutcome::default(), - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: false, - pending_ime_application_change: false, - ime_origin: false, - cache, - layout, - typing, - originals, - picture_looks, - last_command: None, - recording: None, - macros: Default::default(), - versions, - building_blocks: Default::default(), - autosave, - bib_style, - merge, - ui_requests: Vec::new(), - math, - math_latex: false, - math_normal_text: false, - also_selected, - prefs: Prefs::default(), - system_region: Default::default(), - keymap: crate::KeyMap::default(), - ribbon: crate::RibbonLayout::default(), - editing_lang: None, - read_aloud: Default::default(), - password, - #[cfg(not(target_arch = "wasm32"))] - recovery: None, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot, - column, - column_mode, - widened, - edit_marks, - go_back_step, - others: Vec::new(), - order: vec![document_id], - untitled_count: 1, - } - } - - /// 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)]> { - // Cells that are one run of the text are an ordinary stream selection. - self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice()) - } - - /// The whole table cells selected, if the selection is that (see [`CellRange`]). - pub fn cell_range(&self) -> Option<&CellRange> { - self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) - } - - /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where - /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds - /// those). - pub fn sel_ends(&self) -> &Selection { - self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) - } - - pub fn selects_whole_story(&self) -> bool { - let st = self.sel.focus.story; - let (a, b) = self.sel.ordered(); - a == self.doc.start_of(st) && b == self.doc.end_of(st) - } - - /// Whether the caret is in a header or footer. - pub fn in_header_footer(&self) -> bool { - let StoryRef::Part(id) = self.sel.focus.story else { return false }; - self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) - } - - /// 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 - } - - /// How many times the body was replaced under this [`Self::document_id`]. - /// - /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an - /// ordinary edit; it advances for `document.setText`, a version restore, a compare and - /// Undo/Redo, never for typing. - pub fn ime_body_epoch(&self) -> u64 { - self.ime_body_epoch - } - - /// Note that a command replaced the document or its whole body under this document id. - /// - /// The counter fails closed: if it were ever exhausted (unreachable in practice), the - /// document owner id advances too, so a cached IME context still cannot be reused. - pub fn note_body_replaced(&mut self) { - self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { - Some(epoch) => epoch, - None => { - self.document_id = self.document_id.saturating_add(1); - u64::MAX - } - }; - } - 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(&[]) - } - - /// Whether the markup area beside the page can show balloons: markup shown in Print Layout - /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, - /// and Track Changes Options not set to show every revision inline. - pub fn balloons_shown(&self) -> bool { - let v = &self.view; - v.show_markup - && !v.comments_pane - && !v.read_mode - && !v.multi_page - && v.mode == ViewMode::Print - && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline - } - - /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave - /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, - /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. - pub fn revisions_in_balloons(&self) -> bool { - let m = &self.prefs.markup; - self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions - } - - /// Initials for new comments: the ones set in File › Options, else the user name's. - pub fn user_initials(&self) -> String { - let set = self.initials.trim(); - if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() } - } - - /// The document's file name and full path for fields (`FILENAME`) and layout context. - /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default - /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used - /// as a filename fallback (#3449). - pub fn resolved_filenames(&self) -> (String, String) { - if let Some(path) = &self.path { - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()); - let full = path.to_string_lossy().to_string(); - (name, full) - } else if let Some(name) = &self.file_name { - (name.clone(), name.clone()) - } else { - let name = untitled_name(self.untitled); - (name.clone(), name) - } - } - - /// The current layout (recomputed when the document or view changed). - pub fn layout(&mut self) -> Arc { - let typing = self.typing_word(); - self.layout_with(typing) - } - - /// [`Session::layout`] with `typing` as the word being typed. - fn layout_with(&mut self, typing: Option) -> Arc { - let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; - let width = match mode { - ViewMode::Read => self.view.read_width, - ViewMode::Web => self.view.web_width, - _ => DRAFT_WIDTH, - }; - let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; - let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; - // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so - // a view change that moves them in or out of the text lays it out again. - let hide_deleted = !self.view.show_markup; - let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); - let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); - let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); - let (rev, proofing, numeral) = (self.rev, self.view.proofing, self.view.numeral); - let fits = |e: &Option| { - e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { - *r == rev - && (*w == width || mode == ViewMode::Print) - && *h == height - && *m == mode - && *sp == spacing - && *pf == proofing - && *n == numeral - && *hd == deleted - && *o == outline - && *t == typing - && *sh == shown - }) - }; - if !fits(&self.layout[0]) && fits(&self.layout[1]) { - self.layout.swap(0, 1); - } - if fits(&self.layout[0]) - && let Some(e) = &self.layout[0] - { - return e.5.clone(); - } - let (fn_name, fn_path) = self.resolved_filenames(); - let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); - let opts = LayoutOptions { - view: mode, - web_width: width, - read_height: height, - read_line_spacing: spacing.map(f32::from_bits), - show_hidden: shown.0, - hide_deleted, - hide_marked: deleted.1, - deletions_in_balloons: deleted.2, - proofing: self.view.proofing, - numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), - comment_balloons: false, - field_codes: shown.1, - outline: outline.clone(), - proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, - typing_word: typing, - filename: filename.clone(), - filepath: filepath.clone(), - }; - let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - let [newest, _] = std::mem::take(&mut self.layout); - self.layout = [ - Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), - newest, - ]; - l - } - - /// The recorded word while the caret and the word are as typing left them; else drops it. - fn typing_word(&mut self) -> Option { - let t = self.typing.as_ref()?; - let p = if self.sel.is_collapsed() && self.sel.focus == t.at { self.doc.para_at(&t.at) } else { None }; - match p { - Some(p) if typed_range(&p.text, t.at.off) == Some((t.start, t.end)) => { - Some(wordcraft_layout::TypingWord { rev: p.rev, start: t.start, end: t.end }) - } - _ => { - self.typing = None; - None - } - } - } - - /// After a successful command: a `text.*` edit at the caret records the word being typed; - /// any other caret move ends it. - fn note_typing(&mut self, id: &str, before: Option<&Document>, sel_before: &Selection) { - let at = &self.sel.focus; - let edited = || before.is_some_and(|d| d.para_at(at).map(|p| &p.text) != self.doc.para_at(at).map(|p| &p.text)); - if id.starts_with("text.") && edited() { - self.typing = self.word_being_typed(); - } else if self.sel != *sel_before { - self.typing = None; - } - } - - /// The word the collapsed caret ends or sits in, as [`TypedWord`]. - fn word_being_typed(&self) -> Option { - if !self.sel.is_collapsed() { - return None; - } - let at = self.sel.focus.clone(); - let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?; - Some(TypedWord { at, start, end }) - } - /// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931): - /// the command runs on a copy of the document, with this caret and view, and the copy is laid - /// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph - /// paths name the copy's paragraphs, not this document's, and so is the copy's selection, - /// which is where the caret would be. The document, the selection, the undo history and Repeat - /// are untouched. `None` when the command fails or isn't allowed here. - pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc)> { - // The word being typed keeps going without a squiggle on the preview: the command moves - // the copy's caret off it and may split its paragraph, so it is found again by its place. - let typing = self.typing_word(); - let at = self.sel.focus.clone(); - let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone()); - copy.sel = self.sel.clone(); - copy.column = self.column.clone(); - copy.widened = self.widened.clone(); - copy.view = self.view.clone(); - copy.prefs = self.prefs.clone(); - copy.path = self.path.clone(); - copy.file_name = self.file_name.clone(); - copy.author = self.author.clone(); - copy.last_highlight = self.last_highlight; - copy.system_region = self.system_region.clone(); - copy.editing_lang = self.editing_lang.clone(); - // The copy shares the paragraph layouts, so only what the command changed is laid out. - std::mem::swap(&mut copy.cache, &mut self.cache); - let layout = copy.run(id, params).is_ok().then(|| { - let typing = typing.and_then(|t| { - let p = copy.doc.para_at(&at)?; - (t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t }) - }); - copy.layout_with(typing) - }); - std::mem::swap(&mut copy.cache, &mut self.cache); - layout.map(|l| (copy.doc, copy.sel, l)) - } - - /// A layout for output (PDF, images, print): no proofing marks, print view. - pub fn export_layout(&self) -> Arc { - self.export_layout_with(false) - } - - /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup - /// area with the comments in balloons. - pub fn export_layout_with(&self, markup: bool) -> Arc { - let (fn_name, fn_path) = self.resolved_filenames(); - let opts = LayoutOptions { - view: ViewMode::Print, - comment_balloons: markup && self.prefs.markup.comments, - numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), - filename: Some(Arc::from(fn_name.as_str())), - filepath: Some(Arc::from(fn_path.as_str())), - ..Default::default() - }; - 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 = Default::default(); - self.cache.clear(); - } - - /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a - /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no - /// undo steps, no text — switches too, so the blank document a session starts with follows - /// the saved proofing default, or the interface language when none is saved. Nothing else - /// changes and no undo step is added. - pub fn set_editing_language(&mut self, tag: Option<&str>) { - let tag = self - .prefs - .default_language - .as_deref() - .and_then(wordcraft_proof::lang::normalize_tag) - .or_else(|| tag.and_then(wordcraft_proof::lang::normalize_tag)); - if self.editing_lang == tag { - return; - } - self.editing_lang = tag; - let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); - if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { - self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); - self.rev = self.rev.wrapping_add(1); - // Not an edit: the document is as untouched as it was. - if self.made_rev == self.rev.wrapping_sub(1) { - self.made_rev = self.rev; - } - self.relayout(); - } - } - - /// Snapshot for undo before a change. Returns whether it took a step: typing that is - /// already in an open step is not a new one. - pub fn checkpoint(&mut self, label: &str) -> bool { - if label == "Typing" && self.typing_open { - return false; - } - self.typing_open = label == "Typing"; - self.history.push(Arc::new(Undo { - label: label.to_string(), - doc: self.doc.clone(), - sel: self.sel.clone(), - pending: self.pending.clone(), - column: self.column.clone(), - })); - if self.history.len() > MAX_UNDO { - self.history.remove(0); - self.undo_evicted += 1; - } - self.redo.clear(); - // The newest step is what a joined command merges into. - self.join_anchor = true; - true - } - /// 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, pending: self.pending.clone(), column: None })); - if self.history.len() > MAX_UNDO { - self.history.remove(0); - self.undo_evicted += 1; - } - self.typing_open = false; - self.join_anchor = true; - } - /// 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; - } - /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the - /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is - /// not generic: any other command consumes and discards it, so unrelated edits get their own - /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. - pub fn join_next_equation_edit(&mut self) { - self.equation_join_next = true; - } - /// Discard an equation replacement join that received no replacement (for example an empty - /// commit), so it cannot join a later composition's typing. - pub fn clear_equation_join(&mut self) { - self.equation_join_next = false; - } - /// Make the next `text.insert` at the current selection join a successful body - /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to - /// that exact replacement: any other command consumes and discards it, so an intervening - /// ordinary edit gets its own checkpoint. - pub fn join_next_ime_text_replacement(&mut self) { - self.ime_text_join = Some(self.sel.clone()); - } - /// Discard a body replacement join that received no replacement (an empty or refused - /// commit, a new request or a refusal), so it cannot join a later composition. - pub fn clear_ime_text_join(&mut self) { - self.ime_text_join = None; - } - /// Discard every pending IME replacement join. A refused surrounding request supersedes the - /// composition it interrupts, including any promise from an earlier accepted request. - pub fn clear_ime_joins(&mut self) { - self.equation_join_next = false; - self.ime_text_join = None; - } - /// 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). Like Word, the - /// document also keeps the zoom it is shown at (#995). - pub fn stamp_save(&mut self) { - self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); - 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); - } - /// Whether typing is still collecting into one undo step (no other command since). - pub fn typing_open(&self) -> bool { - self.typing_open - } - /// Reopen the typing group when the last undo step is typing, so the keys typed next join - /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). - pub fn continue_typing(&mut self) { - if self.undo_label() == Some("Typing") { - self.typing_open = true; - } - } - /// 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()) - } - /// Undo steps pushed so far, evicted ones included: only grows while steps are added. - pub(crate) fn undo_steps_so_far(&self) -> u64 { - self.history.len() as u64 + self.undo_evicted - } - /// The number of undo steps. - pub fn undo_depth(&self) -> usize { - self.history.len() - } - /// Where the undo history stands, for [`Session::merge_undo_since`]. - pub fn undo_mark(&self) -> u64 { - (self.history.len() as u64).saturating_add(self.undo_evicted) - } - /// Makes the steps recorded since `mark` one undo step, the first: a drag that applied its - /// value as it went (a slider in a pane) undoes in one go. - pub fn merge_undo_since(&mut self, mark: u64) { - let keep = mark.saturating_sub(self.undo_evicted).saturating_add(1); - self.history.truncate(usize::try_from(keep).unwrap_or(usize::MAX)); - } - pub fn undo_labels(&self) -> Vec { - self.history.iter().rev().map(|u| u.label.clone()).collect() - } - /// The Undo button's list (#701): the undo steps' labels (English, as commands name them), - /// newest first, at most [`UNDO_LIST_MAX`]. - pub fn undo_history(&self) -> Vec<&str> { - self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect() - } - /// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701). - /// More than the undo depth undoes everything; a step that would take the document's - /// protection away stops it (only Stop Protection lifts it). Returns the steps undone. - pub fn undo_steps(&mut self, count: usize) -> usize { - let mut done = 0; - while done < count && !self.step_lifts_protection(false) && self.undo() { - done += 1; - } - done - } - /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step - /// was its automatic capital, swaps the document, selection, pending formatting and column with - /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. - pub fn undo(&mut self) -> bool { - self.typing_open = false; - // Undoing the automatic capitalisation's own step makes its word an exception; any - // other Undo forgets the capital. - let steps = self.history.len() as u64 + self.undo_evicted; - if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { - c.add_exceptions(&mut self.prefs.autocorrect); - } - 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), - pending: std::mem::replace(&mut self.pending, u.pending), - column: std::mem::replace(&mut self.column, u.column), - }; - self.redo.push(Arc::new(cur)); - // Undo swaps the whole body back: a composition belongs to the text it showed. - self.note_body_replaced(); - self.refresh_math_session(); - self.touch(); - true - } - /// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it. - pub fn step_lifts_protection(&self, redo: bool) -> bool { - let next = if redo { self.redo.last() } else { self.history.last() }; - next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) - } - /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, - /// refreshing equation editing and touching layout. - 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), - pending: std::mem::replace(&mut self.pending, u.pending), - column: std::mem::replace(&mut self.column, u.column), - }; - self.history.push(Arc::new(cur)); - self.note_body_replaced(); - self.refresh_math_session(); - self.touch(); - true - } - /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the - /// object at that offset, may be a different one now. A new session token starts a new IME - /// context; if tokens are exhausted, the equation is left (fail closed). - fn refresh_math_session(&mut self) { - if self.math.is_none() { - return; - } - match next_math_session() { - Some(session) => { - if let Some(m) = self.math.as_mut() { - m.session = session; - } - } - None => self.math = None, - } - } - - /// Drop history (after open / new). - pub fn reset_history(&mut self) { - self.autocap = None; - self.history.clear(); - self.redo.clear(); - self.typing_open = false; - self.join_anchor = false; - } - - /// The version snapshots belong to the document they were taken from: keeping the old ones - /// would let a restore put the previous document's content into this one (#1327 follow-up). - pub fn clear_versions(&mut self) { - self.versions.clear(); - } - - /// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the - /// system's. - pub fn region(&self) -> wordcraft_geom::Region { - match self.prefs.region.as_deref() { - Some(tag) => wordcraft_geom::Region::from_tag(tag), - None => self.system_region.clone(), - } - } - - /// The English of the region in effect (`en-GB` for a British region), or `None` when the - /// region's language isn't English, it names no territory, or it is the United States (US - /// English is the default anyway). An English interface writes new documents in it, as Word - /// does on a British or Canadian system (#1045). - pub fn region_english(&self) -> Option { - let r = self.region(); - (r.language == "en" && !r.territory.is_empty() && r.territory != "US") - .then(|| format!("en-{}", r.territory)) - .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) - } - - /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan - /// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's - /// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the - /// writer's Spanish from the first word (#3360). - pub fn region_spanish(&self) -> String { - let r = self.region(); - (matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty()) - .then(|| format!("es-{}", r.territory)) - .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) - .unwrap_or_else(|| "es-ES".into()) - } - - /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. - pub fn units(&self) -> wordcraft_geom::Unit { - self.prefs.units.unwrap_or_else(|| self.region().units()) - } - - /// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland). - pub fn format_length(&self, pt: f32) -> String { - self.region().format(self.units(), pt) - } - - /// A new blank document (the Normal template) on the region's paper: Letter in the US and - /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round. - pub fn blank_document(&self) -> Document { - blank_document(&self.region()) - } - - /// Replace the document (open/new). - pub fn set_document(&mut self, doc: Document) { - self.autocap = None; - let replaced = self.document_id; - self.document_id = next_document_id(); - if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { - *at = self.document_id; - } - self.note_body_replaced(); - self.bib_style = doc_bib_style(&doc); - self.doc = doc; - self.doc.ensure_nonempty(); - self.password = None; - self.file_name = None; - // The old document's equation is gone: its editing session cannot carry over. - self.math = None; - self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); - self.pending = None; - self.typing = None; - self.also_selected.clear(); - self.column = None; - self.column_mode = false; - self.widened = None; - self.edit_marks.clear(); - self.new_header_parts.clear(); - self.hf_return = None; - self.go_back_step = 0; - self.view.outline.collapsed.clear(); - // A copy the replaced document kept stays in the recovery list. - #[cfg(not(target_arch = "wasm32"))] - { - self.recovery_slot = Default::default(); - } - self.reset_history(); - self.clear_versions(); - self.touch(); - self.dirty = false; - self.made_rev = self.rev; - 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, the pending `join_next_undo`, - /// `join_next_equation_edit` and body replacement promises, whether the typing group has a - /// step of its own, 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`), clearing it (`file.open` into - /// a blank document) or switching documents 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: _, - last_highlight: _, - path: _, - file_name: _, - // The custom dictionary file, like the file path: host wiring, not an edit. - user_dict_path: _, - dirty, - clipboard: _, - // Like the clipboard: what the next paste can make a move of. - pending_move: _, - clipboard_text: _, - clipboard_html: _, - clip_history: _, - find: _, - goal_x: _, - page_hint: _, - registry: _, - author: _, - initials: _, - painter: _, - status: _, - // Headers just created to edit: closing checks they still exist and are empty. - new_header_parts: _, - hf_return: _, - history, - undo_evicted, - redo, - typing_open, - join_next, - equation_join_next, - ime_text_join, - join_anchor, - autocap: _, - rev: _, - protect_facts: _, - cache: _, - layout: _, - // The word being typed, like `column`: valid only while `sel` is where typing left it. - typing: _, - originals: _, - picture_looks: _, - last_command: _, - recording: _, - macros: _, - versions: _, - building_blocks: _, - autosave: _, - bib_style: _, - merge: _, - ui_requests: _, - document_id: _, - // The untitled name and when the document was made: not edits. - untitled: _, - made_rev: _, - // The other open documents: a snapshot covers the active one. - others: _, - order: _, - untitled_count: _, - ime_body_epoch, - // The IME revision and its caret token are process-global evidence, not document - // state: a rollback must not reuse a revision. - ime_state_revision: _, - ime_state_seen: _, - ime_edit_outcome: _, - #[cfg(any(test, feature = "test-support"))] - ime_state_exhausted: _, - pending_ime_application_change: _, - ime_origin: _, - read_aloud: _, - prefs: _, - system_region: _, - keymap: _, - ribbon: _, - // A preference for new documents, like `prefs`: not part of this document. - editing_lang: _, - // 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: _, - #[cfg(not(target_arch = "wasm32"))] - recovery: _, - #[cfg(not(target_arch = "wasm32"))] - recovery_slot: _, - also_selected: _, - // Column selection and a widened selection's ends, like `sel`'s shape: valid only while - // `sel` matches them. - column: _, - column_mode: _, - widened: _, - // 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, - equation_join_next: *equation_join_next, - ime_text_join: ime_text_join.clone(), - join_anchor: *join_anchor, - dirty: *dirty, - ime_body_epoch: *ime_body_epoch, - } - } - - /// 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, - equation_join_next, - ime_text_join, - join_anchor, - dirty, - ime_body_epoch, - } = 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.autocap = None; - 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.equation_join_next = equation_join_next; - self.ime_text_join = ime_text_join; - self.ime_body_epoch = ime_body_epoch; - self.join_anchor = join_anchor; - 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, - } - } - } - - /// Why command `id` can't run now, for greying it out: its own `enabled` check, then what - /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). - pub fn why_disabled(&self, id: &str) -> Option<&'static str> { - let spec = self.registry.get(id)?; - (spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) - } - - /// Put the revision back to `rev` after a command that changed nothing or failed. What the - /// command cached at the revision it touched to is dropped: that number comes round again - /// with the next edit, for a different document. - fn restore_rev(&mut self, rev: u64) { - if self.rev == rev { - return; - } - self.rev = rev; - for slot in &mut self.layout { - if slot.as_ref().is_some_and(|l| l.0 != rev) { - *slot = None; - } - } - if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { - self.find.highlights = None; - } - let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); - if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) { - *facts = None; - } - } - - /// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision. - fn protect_facts(&self) -> crate::cmd::protect::Facts { - let key = (self.document_id, self.rev, self.author.clone()); - let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some((k, f)) = cache.as_ref() - && *k == key - { - return *f; - } - let f = crate::cmd::protect::Facts::of(self); - *cache = Some((key, f)); - f - } - - /// The current command's explicit IME effect classification. - pub fn ime_edit_outcome(&self) -> ImeEditOutcome { - self.ime_edit_outcome - } - - /// Set the effect classification from a command handler. [`Session::run`] fills - /// `selection_changed` and enforces the content/state invariant after the command. - pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { - self.ime_edit_outcome = outcome; - } - - /// Take the pending application-origin change signal. The UI owns one session, so a simple - /// bool is enough; it deliberately survives later commands until drained. - pub fn take_pending_ime_application_change(&mut self) -> bool { - std::mem::take(&mut self.pending_ime_application_change) - } - - /// Simulate a globally exhausted IME revision allocator in this session (test support). - /// - /// This only ever makes receipt classification fail closed. It exists so front-end producer - /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` - /// stamp; production builds do not compile it. - #[cfg(any(test, feature = "test-support"))] - #[doc(hidden)] - pub fn exhaust_ime_state_revision(&mut self) { - self.ime_state_exhausted = true; - self.ime_state_revision = None; - } - - /// The checked, process-monotonic revision for IME receipts with unavailable surrounding - /// text. Selection/caret motion allocates on the next call; `None` means exhausted. - pub fn ime_state_revision(&mut self) -> Option { - self.sync_ime_caret_state(); - self.ime_state_revision - } - - /// Returns the caret identity used for IME state revisions: the equation session and position - /// while editing, otherwise the body selection. - fn current_ime_caret_state(&self) -> ImeCaretState { - match &self.math { - Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, - None => ImeCaretState::Body(self.sel.clone()), - } - } - - /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. - /// Test support can pin exhaustion for this session so a front-end producer test sees the - /// real `ime_stamp`/receipt path fail closed. - fn reset_ime_state_revision(&mut self) { - #[cfg(any(test, feature = "test-support"))] - if self.ime_state_exhausted { - self.ime_state_revision = None; - return; - } - // v5: allocation failure must store `None`, never retain a stale revision. - self.ime_state_revision = next_ime_state_revision(); - } - - /// Allocates a new revision only when the body selection or the equation session and position - /// differs from the last observed state. - fn sync_ime_caret_state(&mut self) { - let state = self.current_ime_caret_state(); - if self.ime_state_seen.as_ref() != Some(&state) { - self.ime_state_seen = Some(state); - self.reset_ime_state_revision(); - } - } - - /// Run one command as the input method's own transaction. - /// - /// Commands run inside this scope are IME-origin: a successful command run *outside* this - /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit - /// the origin. The previous origin is restored on every return, including unknown, disabled or - /// refused commands. - pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { - let previous = std::mem::replace(&mut self.ime_origin, true); - let result = self.run(id, params); - self.ime_origin = previous; - result - } - - /// Whether the command changed nothing: the document, the selection, the formatting - /// waiting at the caret and the column selection are as they were before it ran. - /// - /// A command that changes nothing is not an edit: it adds no undo step, marks no - /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). - fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { - doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending - } - - /// 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 { - // The document the command starts in, and the first id a document it opens can have. - let home = self.document_id; - let mark = document_id_mark(); - // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, - // protected) leave it at the default. - self.ime_edit_outcome = ImeEditOutcome::default(); - // A rolled-back transaction must not leave a successful application-change signal from a - // nested command that ran before the failure. - let pending_application_change_before = self.pending_ime_application_change; - let join = std::mem::take(&mut self.join_next); - // The equation replacement promise is not a generic undo join: only the command that - // actually types into the equation may consume it. Any other command discards it. - let equation_join = std::mem::take(&mut self.equation_join_next); - // The body replacement promise is likewise scoped: only the `text.insert` at the - // selection the deletion left may consume it. - let ime_text_join = self.ime_text_join.take(); - let reg = self.registry.clone(); - let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; - let join = join - || (equation_join && matches!(id, "equation.type" | "equation.backspace")) - || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); - if let Some(why) = (spec.enabled)(self) { - return Err(CmdError::Disabled(format!("{id}: {why}"))); - } - // Restrict Editing: refused outright, or allowed as long as it only edits the exceptions. - let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false }; - // Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike). - let blocks_before = guard.then(|| self.building_blocks.clone()); - if spec.mutates && self.doc.settings.protection.as_deref() == Some("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.")); - // A password never lands in a recorded macro or Repeat. - let kept = || { - let mut p = params.clone(); - if let Some(m) = p.as_object_mut() { - m.remove("password"); - } - p - }; - if record && let Some((_, steps)) = self.recording.as_mut() { - steps.push((id.to_string(), kept())); - } - if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { - self.last_command = Some((id.to_string(), kept())); - } - // 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 ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); - // Commands may leave an equation (a whole-body replacement) even after they fail; put - // the editing session back with the document. - let math_before = spec.mutates.then(|| self.math.clone()); - let before_doc: Option = 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 - }; - // The formatting waiting at the caret (⌘B with nothing selected): an undo step restores - // it, so a command that only changes it is an edit. - let pending_before = spec.mutates.then(|| self.pending.clone()); - // Which document is open. A command that replaces the document (a mail merge) opens a - // new one, with a new undo history: it is not an edit of this document, whatever the - // new document holds. - let document_id_before = self.document_id; - // Whether the document has unsaved changes, and the revision its layout is keyed on: a - // command that changes nothing leaves both as they were, and a failed one leaves no - // trace. - let dirty_before = spec.mutates.then_some(self.dirty); - let rev_restore = spec.mutates.then_some(self.rev); - // These mutating commands can replace the open document and discard its saved versions. - // Keep the versions until the command and the protected-region post-check have both - // succeeded; rejected commands must leave the old document and its snapshots together. - // `mailings.finish` with a path saves the merged copy there and leaves the open - // document alone, so it replaces nothing. - let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels") - || (id == "mailings.finish" && crate::p::str(params, "path").is_none()); - let versions_before = replaces_document.then(|| self.versions.clone()); - let identity_before = replaces_document.then(|| { - ( - self.path.clone(), - self.file_name.clone(), - self.document_id, - self.password.clone(), - self.dirty, - self.bib_style.clone(), - self.merge.preview, - ) - }); - let typing = spec.id == "text.insert" || spec.id == "equation.type"; - if spec.mutates && !typing { - self.typing_open = false; - } - // Whether this command pushed an undo step (a no-op command's step is dropped below, - // leaving its undo group without one). - let mut pushed = false; - if spec.mutates { - let label = if typing { "Typing" } else { spec.label }; - // A joined command merges into its group's step; a group whose first command - // changed nothing has none, so the next command in it starts one (#4001). - if !join || self.history.is_empty() || !self.join_anchor { - pushed = 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; - } - // Only the paste right after a tracked cut makes it a move. - if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") - { - self.pending_move = None; - } - let sel_before = self.sel.clone(); - // A rejected command must not persist a new proofing default, including a rejection - // by the protected-region check after its handler has succeeded. Undo stays document-only. - let default_language_before = self.prefs.default_language.clone(); - let run = spec.run; - let mutates = spec.mutates; - let ime_caret_before = self.current_ime_caret_state(); - let rev_before = self.rev; - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params))); - let mut result = match result { - Ok(r) => r, - Err(p) => { - let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); - Err(CmdError::Failed(format!("{id} panicked: {msg}"))) - } - }; - if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { - return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before, pending_application_change_before); - } - // Restrict Editing: outside the exceptions nothing may change. - if result.is_ok() - && guard - && let Some((before, ..)) = &before_doc - && !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author) - { - result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change"))); - } - match &result { - Ok(_) => { - let mut no_op = false; - 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.doc.ensure_nonempty(); - self.doc.prune_text_boxes(); - // Whether the command left the document, the selection and the formatting - // waiting at the caret as they were. A command that replaces the document — - // a mail merge, a comparison — is not an edit of this one, whatever the new - // document holds: `replaces_document` names the commands that may replace - // it, and `document_id` catches any other that does (through - // `set_document`, which starts a new history the old one must not overwrite). - let unchanged = |s: &Session| match (&before_doc, &pending_before) { - (Some((doc, sel, _, _, _, _, column)), Some(pending)) => { - !replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending) - } - _ => false, - }; - let changed = !unchanged(self); - if changed { - // The layout the connectors are rerouted against is the new one. - self.touch(); - } - // Glued connectors follow the shapes they join. - let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self); - if rerouted { - self.touch(); - } - // A command that changed nothing is not an edit (#23): it adds no undo - // step, marks no unsaved changes, forces no relayout and leaves the redo - // stack alone. What it left behind is compared with what it started from, - // after the reroute: a reroute that moved a connector is an edit of its own. - no_op = !changed && !rerouted && unchanged(self); - if no_op { - // The step the dispatch pushed is not an edit: put the stacks back as - // they were, so the redo stack survives and no step is evicted. The - // group it belonged to is left without a step of its own, so the next - // command in it starts one. - if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc { - self.history = history.clone(); - self.redo = redo.clone(); - self.typing_open = *typing_open; - self.undo_evicted = *evicted; - } - if let Some(dirty) = dirty_before { - self.dirty = dirty; - } - if let Some(rev) = rev_restore { - let touched = self.rev; - self.restore_rev(rev); - crate::cmd::draw::rekey_lasso(self, touched, rev); - } - if pushed { - self.join_anchor = false; - } - } - } - self.clamp_selection(); - let mut ime_outcome = self.ime_edit_outcome; - if ime_outcome.content_changed { - ime_outcome.state_changed = true; - } - // An unclassified mutating command is conservatively a state change. It can make - // a frame Other, but it can never mint a receipt because `classified` is false. - // A command that changed nothing is not an edit (#23), so it is not an IME - // state change either: otherwise Backspace at the document start would still - // signal an application change and move the settled IME revision. - if !ime_outcome.classified && mutates && !no_op { - ime_outcome.state_changed = true; - } - ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; - self.ime_edit_outcome = ime_outcome; - if ime_outcome.state_changed { - let caret = self.current_ime_caret_state(); - self.ime_state_seen = Some(caret); - self.reset_ime_state_revision(); - } - if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { - self.pending_ime_application_change = true; - } - if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { - self.hf_return = Some(sel_before.clone()); - } - // A selection command that moves the insertion point ends the typing group, like - // caret movement: typing, Select, typing are two undo steps (#419). - if !spec.mutates && self.sel != sel_before { - self.typing_open = false; - } - if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") { - self.note_edit(); - } - // Outline view: collapsed headings follow edits; the caret stays where it shows. - crate::cmd::outline::after_command(self, &sel_before, self.rev != rev_before); - // Extra selected objects last until something else is edited or selected; formatting - // them keeps them selected, as in Word. - if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != sel_before) - { - self.also_selected.clear(); - } - self.note_typing(id, before_doc.as_ref().map(|b| &b.0), &sel_before); - } - Err(e) => { - self.ime_edit_outcome = ImeEditOutcome::default(); - self.pending_ime_application_change = pending_application_change_before; - self.autocap = None; - self.prefs.default_language = default_language_before; - if let Some(epoch) = ime_epoch_before { - self.ime_body_epoch = epoch; - } - if let Some(math) = math_before { - self.math = math; - } - if let Some(versions) = versions_before { - self.versions = versions; - } - if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before { - self.path = path; - self.file_name = file_name; - self.document_id = document_id; - self.password = password; - self.dirty = dirty; - self.bib_style = bib_style; - self.merge.preview = merge_preview; - } - 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; - // Its step is gone with them: its undo group has none to merge into. - if pushed { - self.join_anchor = false; - } - } - if let Some(dirty) = dirty_before { - self.dirty = dirty; - } - if let Some(rev) = rev_restore { - self.restore_rev(rev); - } - if let Some(b) = blocks_before { - self.building_blocks = b; - } - self.status = e.to_string(); - } - } - result - } - - /// The end of [`Session::run`] for a command that ended up in another document than it - /// started in (it opened, made, switched to or closed one) or that failed after opening one. - /// The checks that compare the document before and after the command don't apply. - /// Succeeded: the first document loses the undo step made for the command when its text is - /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is - /// active and as it was. - fn finish_elsewhere( - &mut self, - home: u64, - mark: u64, - result: Result, - before_doc: Option, - blocks_before: Option>, - default_language_before: Option, - pending_application_change_before: bool, - ) -> Result { - // Another document is a state change the input method has to see, never an IME edit. - self.ime_edit_outcome = ImeEditOutcome::default(); - match &result { - Ok(_) => { - self.put_back_parked(home, before_doc); - self.clamp_selection(); - self.ime_state_seen = Some(self.current_ime_caret_state()); - self.reset_ime_state_revision(); - if !self.ime_origin { - self.pending_ime_application_change = true; - } - } - Err(e) => { - self.pending_ime_application_change = pending_application_change_before; - self.autocap = None; - self.prefs.default_language = default_language_before; - if self.is_open(home) { - let _ = self.switch_to(home); - let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); - for d in opened { - let _ = self.close_document(d); - } - 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; - self.undo_evicted = ev; - self.column = c; - } - } - if let Some(b) = blocks_before { - self.building_blocks = b; - } - 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: the caret's, except run direction. New text - /// takes its direction from its own letters, as on screen, so Latin typed or inserted into - /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces - /// itself (#1543). The bidi language stays (it only applies to complex-script text). - /// Over a selection, as in Word, typing takes the first selected character's formatting, - /// whichever way the selection was made (#2879). - pub fn typing_props(&self) -> CharProps { - let (a, _) = self.sel.ordered(); - let first = match self.doc.para_at(&a) { - Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)), - _ => None, - }; - let mut c = match first { - Some(props) => { - // The selection is replaced, so the new text isn't part of a link or a note number. - let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() }; - if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { - out.style = None; - } - out - } - None => self.caret_props(), - }; - c.rtl = None; - c - } - - /// The caret's formatting, as Reveal Formatting shows it. - pub fn caret_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; - // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). - let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); - // A footnote or endnote number is raised by its character style; text typed after it is body text. - if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { - out.style = None; - } - out - } - - /// 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 { - // A column block or whole cells that aren't one run of the text: each piece's own text, - // a line each, so the status bar and Word Count count those cells only. - if let Some(segs) = self.column_segments() { - return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::>().join("\n"); - } - let (a, b) = self.sel.ordered(); - self.doc.copy_range(&a, &b).plain_text() - } -} - -/// A new blank document on `region`'s paper (see [`Session::blank_document`]). -pub fn blank_document(region: &wordcraft_geom::Region) -> Document { - let mut d = Document::new(); - let (_, w, h) = region.paper(); - d.last_section.page_w = w; - d.last_section.page_h = h; - d -} - -#[cfg(test)] -mod ime_revision_tests { - use super::*; - use serde_json::json; - - /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. - #[test] - fn checked_revision_allocation_refuses_to_wrap() { - let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); - assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); - assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); - assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); - } - - /// An unchanged caret reuses its revision, while a direct selection assignment is observed and - /// advances the revision. - #[test] - fn selection_and_caret_motion_advance_the_unavailable_revision() { - let mut s = Session::new(Document::from_text("hello")); - let first = s.ime_state_revision().expect("a revision"); - assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); - s.sel = Selection::caret(Pos::body(0, 2)); - let moved = s.ime_state_revision().expect("a revision"); - assert!(moved > first, "a direct selection assignment is caught"); - } - - /// A selection-only command advances the revision on the next read, while a committed state - /// change advances it immediately. - #[test] - fn successful_state_changes_allocate_but_selection_only_commands_do_not() { - let mut s = Session::new(Document::from_text("hello")); - let first = s.ime_state_revision().expect("a revision"); - s.run("select.all", &json!({})).unwrap(); - // `run` does not allocate for selection-only work; the next stamp sees the new caret. - assert!(s.ime_state_revision() > Some(first)); - let selected = s.ime_state_revision().expect("a revision"); - s.run("para.alignCenter", &json!({})).unwrap(); - assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); - } - - /// After the revision allocator is exhausted, a real equation edit still reports its classified - /// change but cannot mint a revision. - #[test] - fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { - let mut s = Session::new(Document::new()); - s.run("insert.equation", &json!({})).unwrap(); - s.exhaust_ime_state_revision(); - assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); - s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); - let outcome = s.ime_edit_outcome(); - assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); - assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); - } - - /// The application-change signal is consumed once; commands run through `run_ime` do not set it, - /// while a normal command does. - #[test] - fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { - let mut s = Session::new(Document::from_text("hello")); - s.run("para.alignCenter", &json!({})).unwrap(); - assert!(s.take_pending_ime_application_change(), "a normal command signals"); - assert!(!s.take_pending_ime_application_change(), "taking clears it"); - s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); - assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); - s.run("text.insert", &json!({"text": "y"})).unwrap(); - assert!(s.take_pending_ime_application_change()); - } - - /// Unknown and refused IME commands restore the normal origin without signalling an application - /// change, and a nested command inside a macro inherits the IME origin. - #[test] - fn ime_origin_is_restored_and_nested_commands_inherit_it() { - let mut s = Session::new(Document::from_text("hello")); - s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); - s.take_pending_ime_application_change(); - - // Unknown and refused commands restore the normal application origin; the failed run - // itself leaves no application-change signal. - assert!(s.run_ime("no.such.command", &json!({})).is_err()); - assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); - assert!(s.run_ime("equation.backspace", &json!({})).is_err()); - assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); - - // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is - // an application change. - s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); - assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); - s.run("tools.macros", &json!({"run": "centre"})).unwrap(); - assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); - } } diff --git a/crates/engine/src/session/doc_state.rs b/crates/engine/src/session/doc_state.rs new file mode 100644 index 000000000..2cc6f0092 --- /dev/null +++ b/crates/engine/src/session/doc_state.rs @@ -0,0 +1,180 @@ +use super::*; + +/// 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()) } + } +} + +/// The word being typed, left without a spelling squiggle until the caret leaves it (#1552). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct TypedWord { + /// Where the caret was when the word was typed. + pub(super) at: Pos, + /// The word's byte range in its paragraph. + pub(super) start: usize, + pub(super) end: usize, +} + +/// 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)>, + /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in + /// a table's selection areas): one segment per paragraph of the cells. + pub cells: Option, +} + +/// Whole table cells selected: rows `rows.0..=rows.1` and every cell of them that reaches into +/// grid columns `cols.0..=cols.1`. +#[derive(Clone, Debug, PartialEq)] +pub struct CellRange { + pub story: StoryRef, + pub table: Path, + pub rows: (usize, usize), + pub cols: (usize, usize), + /// The cells are one run of the text (cells of one row, or whole rows), so the stream + /// selection between the corners holds just them and commands act on that as usual. + /// Otherwise (part of a column) text edits and formatting go cell by cell. + pub stream: bool, +} + +impl CellRange { + /// The selected cells of table `t` (row, cell index), in document order. + pub fn cells(&self, t: &Table) -> Vec<(usize, usize)> { + let ((r0, r1), (g0, g1)) = (self.rows, self.cols); + let mut out = Vec::new(); + for (r, row) in t.rows.iter().enumerate().take(r1.saturating_add(1)).skip(r0) { + let mut g = row.first_grid(); + for (c, cell) in row.cells.iter().enumerate() { + let end = g.saturating_add(cell.span()); + if g <= g1 && end > g0 { + out.push((r, c)); + } + g = end; + } + } + out + } +} + +/// A selection Word's table rule widened to whole cells or rows (`cmd::table::extend_selection`), +/// and where it really runs. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Widened { + /// The selection it became; this is current while `Session::sel` still equals it. + pub sel: Selection, + /// Where the selection was really extended from and to: extending it again goes on from here. + pub ends: Selection, +} + +/// 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, + /// Word's "Use wildcards" (#543). + pub wildcards: bool, + /// Take Find what literally: no `^` codes (`special: false`). + pub literal: bool, + pub match_prefix: bool, + pub match_suffix: bool, + pub ignore_punct: bool, + pub ignore_space: 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)] +pub(super) struct Undo { + pub(super) label: String, + pub(super) doc: Document, + pub(super) sel: Selection, + /// Formatting waiting at the caret (Bold with nothing selected), so Undo takes it back too. + pub(super) pending: Option, + /// The column block or whole cells the selection was (undoing brings the selection back as + /// it was). + pub(super) column: Option, +} + +/// The number of edit locations Go Back cycles through (Word: four). +pub(super) const EDIT_MARKS: usize = 4; + +/// The equation being edited. +#[derive(Clone, Debug, PartialEq)] +pub struct MathEdit { + /// The equation object (its U+FFFC). This moves as the document is edited, so it is not an + /// IME owner; [`Self::session`] is. + pub at: Pos, + /// The caret inside it. + pub pos: wordcraft_doc::math_edit::MathPos, + /// A stable token for this editing session, unique across documents and re-entry. + pub session: u64, +} + +/// Next stable token for an equation editing session, or `None` when the counter is exhausted. +/// +/// Exhaustion fails closed: a repeated token could let a cached IME context outlive the +/// equation it described, so the caller refuses to start editing instead. +pub fn next_math_session() -> Option { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + NEXT.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +/// The citation style a document records (References › Style), APA when it records none or +/// one we don't know. +/// The byte range of the word the caret at `off` ends or sits in, counting apostrophes and +/// hyphens as the spelling check does; `None` unless a letter or digit comes before the caret. +pub(super) fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { + let in_word = |c: char| wordcraft_doc::bidi::is_word_char(c) || matches!(c, '’' | '-'); + let before = text.get(..off)?; + let start = before.char_indices().rev().take_while(|&(_, c)| in_word(c)).last().map(|(i, _)| i)?; + let end = off.saturating_add(text.get(off..)?.chars().take_while(|&c| in_word(c)).map(char::len_utf8).sum::()); + text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) +} + +/// The name of `doc`'s bibliography style, APA when it names none we know. +pub(super) fn doc_bib_style(doc: &Document) -> String { + doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() +} + +/// Most pending front-end requests a session keeps. +pub const MAX_UI_REQUESTS: usize = 64; + +/// Maximum undo depth. +pub(super) const MAX_UNDO: usize = 500; + +/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further. +pub const UNDO_LIST_MAX: usize = 100; diff --git a/crates/engine/src/session/history.rs b/crates/engine/src/session/history.rs new file mode 100644 index 000000000..3bf669636 --- /dev/null +++ b/crates/engine/src/session/history.rs @@ -0,0 +1,258 @@ +use super::doc_state::{MAX_UNDO, Selection, UNDO_LIST_MAX, Undo, next_math_session}; +use super::*; + +impl Session { + /// Snapshot for undo before a change. Returns whether it took a step: typing that is + /// already in an open step is not a new one. + pub fn checkpoint(&mut self, label: &str) -> bool { + if label == "Typing" && self.typing_open { + return false; + } + self.typing_open = label == "Typing"; + self.history.push(Arc::new(Undo { + label: label.to_string(), + doc: self.doc.clone(), + sel: self.sel.clone(), + pending: self.pending.clone(), + column: self.column.clone(), + })); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + self.undo_evicted += 1; + } + self.redo.clear(); + // The newest step is what a joined command merges into. + self.join_anchor = true; + true + } + + /// 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, pending: self.pending.clone(), column: None })); + if self.history.len() > MAX_UNDO { + self.history.remove(0); + self.undo_evicted += 1; + } + self.typing_open = false; + self.join_anchor = true; + } + + /// 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; + } + + /// Make the next equation typing command (`equation.type` or `equation.backspace`) join the + /// previous equation deletion's undo step. Unlike [`Self::join_next_undo`], the promise is + /// not generic: any other command consumes and discards it, so unrelated edits get their own + /// checkpoint. A deletion-only IME request therefore cannot leak a join into the next edit. + pub fn join_next_equation_edit(&mut self) { + self.equation_join_next = true; + } + + /// Discard an equation replacement join that received no replacement (for example an empty + /// commit), so it cannot join a later composition's typing. + pub fn clear_equation_join(&mut self) { + self.equation_join_next = false; + } + + /// Make the next `text.insert` at the current selection join a successful body + /// `DeleteSurrounding`'s undo step. Unlike [`Self::join_next_undo`], the promise is scoped to + /// that exact replacement: any other command consumes and discards it, so an intervening + /// ordinary edit gets its own checkpoint. + pub fn join_next_ime_text_replacement(&mut self) { + self.ime_text_join = Some(self.sel.clone()); + } + + /// Discard a body replacement join that received no replacement (an empty or refused + /// commit, a new request or a refusal), so it cannot join a later composition. + pub fn clear_ime_text_join(&mut self) { + self.ime_text_join = None; + } + + /// Discard every pending IME replacement join. A refused surrounding request supersedes the + /// composition it interrupts, including any promise from an earlier accepted request. + pub fn clear_ime_joins(&mut self) { + self.equation_join_next = false; + self.ime_text_join = None; + } + + /// 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). Like Word, the + /// document also keeps the zoom it is shown at (#995). + pub fn stamp_save(&mut self) { + self.doc.settings.zoom = crate::cmd::view::saved_zoom(&self.view); + 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); + } + + /// Whether typing is still collecting into one undo step (no other command since). + pub fn typing_open(&self) -> bool { + self.typing_open + } + + /// Reopen the typing group when the last undo step is typing, so the keys typed next join + /// it: after an input method's rewrite was joined to it (`join_next_undo` closes the group). + pub fn continue_typing(&mut self) { + if self.undo_label() == Some("Typing") { + self.typing_open = true; + } + } + + /// 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()) + } + + /// Undo steps pushed so far, evicted ones included: only grows while steps are added. + pub(crate) fn undo_steps_so_far(&self) -> u64 { + self.history.len() as u64 + self.undo_evicted + } + + /// The number of undo steps. + pub fn undo_depth(&self) -> usize { + self.history.len() + } + + /// Where the undo history stands, for [`Session::merge_undo_since`]. + pub fn undo_mark(&self) -> u64 { + (self.history.len() as u64).saturating_add(self.undo_evicted) + } + + /// Makes the steps recorded since `mark` one undo step, the first: a drag that applied its + /// value as it went (a slider in a pane) undoes in one go. + pub fn merge_undo_since(&mut self, mark: u64) { + let keep = mark.saturating_sub(self.undo_evicted).saturating_add(1); + self.history.truncate(usize::try_from(keep).unwrap_or(usize::MAX)); + } + + pub fn undo_labels(&self) -> Vec { + self.history.iter().rev().map(|u| u.label.clone()).collect() + } + + /// The Undo button's list (#701): the undo steps' labels (English, as commands name them), + /// newest first, at most [`UNDO_LIST_MAX`]. + pub fn undo_history(&self) -> Vec<&str> { + self.history.iter().rev().take(UNDO_LIST_MAX).map(|u| u.label.as_str()).collect() + } + + /// Undo the newest `count` steps in one go, as picking an entry in the Undo list does (#701). + /// More than the undo depth undoes everything; a step that would take the document's + /// protection away stops it (only Stop Protection lifts it). Returns the steps undone. + pub fn undo_steps(&mut self, count: usize) -> usize { + let mut done = 0; + while done < count && !self.step_lifts_protection(false) && self.undo() { + done += 1; + } + done + } + + /// Undoes the newest step: closes typing, records an AutoCorrect exception when the undone step + /// was its automatic capital, swaps the document, selection, pending formatting and column with + /// the redo snapshot, replaces the body identity, refreshes equation editing and touches layout. + pub fn undo(&mut self) -> bool { + self.typing_open = false; + // Undoing the automatic capitalisation's own step makes its word an exception; any + // other Undo forgets the capital. + let steps = self.history.len() as u64 + self.undo_evicted; + if let Some(c) = self.autocap.take().filter(|c| c.depth == steps) { + c.add_exceptions(&mut self.prefs.autocorrect); + } + 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), + pending: std::mem::replace(&mut self.pending, u.pending), + column: std::mem::replace(&mut self.column, u.column), + }; + self.redo.push(Arc::new(cur)); + // Undo swaps the whole body back: a composition belongs to the text it showed. + self.note_body_replaced(); + self.refresh_math_session(); + self.touch(); + true + } + + /// Would Undo (or Redo) take the document's protection away? Only Stop Protection lifts it. + pub fn step_lifts_protection(&self, redo: bool) -> bool { + let next = if redo { self.redo.last() } else { self.history.last() }; + next.is_some_and(|u| crate::cmd::protect::protection_lifted(&self.doc, &u.doc)) + } + + /// Redoes the newest undone step by swapping the snapshots back, replacing the body identity, + /// refreshing equation editing and touching layout. + 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), + pending: std::mem::replace(&mut self.pending, u.pending), + column: std::mem::replace(&mut self.column, u.column), + }; + self.history.push(Arc::new(cur)); + self.note_body_replaced(); + self.refresh_math_session(); + self.touch(); + true + } + + /// An equation's editing session cannot ride across Undo/Redo: the equation tree, or the + /// object at that offset, may be a different one now. A new session token starts a new IME + /// context; if tokens are exhausted, the equation is left (fail closed). + fn refresh_math_session(&mut self) { + if self.math.is_none() { + return; + } + match next_math_session() { + Some(session) => { + if let Some(m) = self.math.as_mut() { + m.session = session; + } + } + None => self.math = None, + } + } + + /// Drop history (after open / new). + pub fn reset_history(&mut self) { + self.autocap = None; + self.history.clear(); + self.redo.clear(); + self.typing_open = false; + self.join_anchor = false; + } + + /// The version snapshots belong to the document they were taken from: keeping the old ones + /// would let a restore put the previous document's content into this one (#1327 follow-up). + pub fn clear_versions(&mut self) { + self.versions.clear(); + } +} diff --git a/crates/engine/src/session/ime.rs b/crates/engine/src/session/ime.rs new file mode 100644 index 000000000..e31e2f0e3 --- /dev/null +++ b/crates/engine/src/session/ime.rs @@ -0,0 +1,85 @@ +use super::ime_state::{ImeCaretState, next_ime_state_revision}; +use super::*; + +impl Session { + /// The current command's explicit IME effect classification. + pub fn ime_edit_outcome(&self) -> ImeEditOutcome { + self.ime_edit_outcome + } + + /// Set the effect classification from a command handler. [`Session::run`] fills + /// `selection_changed` and enforces the content/state invariant after the command. + pub(crate) fn set_ime_edit_outcome(&mut self, outcome: ImeEditOutcome) { + self.ime_edit_outcome = outcome; + } + + /// Take the pending application-origin change signal. The UI owns one session, so a simple + /// bool is enough; it deliberately survives later commands until drained. + pub fn take_pending_ime_application_change(&mut self) -> bool { + std::mem::take(&mut self.pending_ime_application_change) + } + + /// Simulate a globally exhausted IME revision allocator in this session (test support). + /// + /// This only ever makes receipt classification fail closed. It exists so front-end producer + /// tests can exercise the real `ime_stamp` path instead of hand-building an `Unavailable` + /// stamp; production builds do not compile it. + #[cfg(any(test, feature = "test-support"))] + #[doc(hidden)] + pub fn exhaust_ime_state_revision(&mut self) { + self.ime_state_exhausted = true; + self.ime_state_revision = None; + } + + /// The checked, process-monotonic revision for IME receipts with unavailable surrounding + /// text. Selection/caret motion allocates on the next call; `None` means exhausted. + pub fn ime_state_revision(&mut self) -> Option { + self.sync_ime_caret_state(); + self.ime_state_revision + } + + /// Returns the caret identity used for IME state revisions: the equation session and position + /// while editing, otherwise the body selection. + pub(super) fn current_ime_caret_state(&self) -> ImeCaretState { + match &self.math { + Some(m) => ImeCaretState::Equation { session: m.session, pos: m.pos.clone() }, + None => ImeCaretState::Body(self.sel.clone()), + } + } + + /// Allocate the next IME state revision, or store `None` when the allocator is exhausted. + /// Test support can pin exhaustion for this session so a front-end producer test sees the + /// real `ime_stamp`/receipt path fail closed. + pub(super) fn reset_ime_state_revision(&mut self) { + #[cfg(any(test, feature = "test-support"))] + if self.ime_state_exhausted { + self.ime_state_revision = None; + return; + } + // v5: allocation failure must store `None`, never retain a stale revision. + self.ime_state_revision = next_ime_state_revision(); + } + + /// Allocates a new revision only when the body selection or the equation session and position + /// differs from the last observed state. + fn sync_ime_caret_state(&mut self) { + let state = self.current_ime_caret_state(); + if self.ime_state_seen.as_ref() != Some(&state) { + self.ime_state_seen = Some(state); + self.reset_ime_state_revision(); + } + } + + /// Run one command as the input method's own transaction. + /// + /// Commands run inside this scope are IME-origin: a successful command run *outside* this + /// scope sets the pending application-change signal, but nothing inside it does. Nested commands inherit + /// the origin. The previous origin is restored on every return, including unknown, disabled or + /// refused commands. + pub fn run_ime(&mut self, id: &str, params: &Value) -> Result { + let previous = std::mem::replace(&mut self.ime_origin, true); + let result = self.run(id, params); + self.ime_origin = previous; + result + } +} diff --git a/crates/engine/src/session/ime_state.rs b/crates/engine/src/session/ime_state.rs new file mode 100644 index 000000000..01c148d21 --- /dev/null +++ b/crates/engine/src/session/ime_state.rs @@ -0,0 +1,115 @@ +use super::doc_state::Selection; +#[cfg(test)] +use super::*; + +/// The caret identity used for the process-monotonic IME state revision. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum ImeCaretState { + Body(Selection), + Equation { session: u64, pos: wordcraft_doc::math_edit::MathPos }, +} + +/// One checked allocation from a process-monotonic IME revision counter. +fn allocate_ime_state_revision(counter: &std::sync::atomic::AtomicU64) -> Option { + counter.try_update(std::sync::atomic::Ordering::Relaxed, std::sync::atomic::Ordering::Relaxed, |n| n.checked_add(1)).ok() +} + +static IME_STATE_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +/// Allocates the next process-monotonic IME state revision, returning `None` when the counter is +/// exhausted. +pub(super) fn next_ime_state_revision() -> Option { + allocate_ime_state_revision(&IME_STATE_REVISION) +} + +#[cfg(test)] +mod ime_revision_tests { + use super::*; + use serde_json::json; + + /// Checked allocations stop at `u64::MAX` and report exhaustion instead of wrapping. + #[test] + fn checked_revision_allocation_refuses_to_wrap() { + let counter = std::sync::atomic::AtomicU64::new(u64::MAX - 1); + assert_eq!(allocate_ime_state_revision(&counter), Some(u64::MAX - 1)); + assert_eq!(allocate_ime_state_revision(&counter), None, "exhaustion fails closed"); + assert_eq!(counter.load(std::sync::atomic::Ordering::Relaxed), u64::MAX); + } + + /// An unchanged caret reuses its revision, while a direct selection assignment is observed and + /// advances the revision. + #[test] + fn selection_and_caret_motion_advance_the_unavailable_revision() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + assert_eq!(s.ime_state_revision(), Some(first), "an unchanged caret reuses its revision"); + s.sel = Selection::caret(Pos::body(0, 2)); + let moved = s.ime_state_revision().expect("a revision"); + assert!(moved > first, "a direct selection assignment is caught"); + } + + /// A selection-only command advances the revision on the next read, while a committed state + /// change advances it immediately. + #[test] + fn successful_state_changes_allocate_but_selection_only_commands_do_not() { + let mut s = Session::new(Document::from_text("hello")); + let first = s.ime_state_revision().expect("a revision"); + s.run("select.all", &json!({})).unwrap(); + // `run` does not allocate for selection-only work; the next stamp sees the new caret. + assert!(s.ime_state_revision() > Some(first)); + let selected = s.ime_state_revision().expect("a revision"); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.ime_state_revision() > Some(selected), "a committed state change advances"); + } + + /// After the revision allocator is exhausted, a real equation edit still reports its classified + /// change but cannot mint a revision. + #[test] + fn exhausted_allocator_keeps_a_real_equation_commit_uncertifiable() { + let mut s = Session::new(Document::new()); + s.run("insert.equation", &json!({})).unwrap(); + s.exhaust_ime_state_revision(); + assert_eq!(s.ime_state_revision(), None, "exhaustion fails closed"); + s.run_ime("equation.type", &json!({"text": "x"})).unwrap(); + let outcome = s.ime_edit_outcome(); + assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}"); + assert_eq!(s.ime_state_revision(), None, "a real producer command cannot mint a revision"); + } + + /// The application-change signal is consumed once; commands run through `run_ime` do not set it, + /// while a normal command does. + #[test] + fn application_origin_signal_is_taken_once_and_ime_commands_do_not_set_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("para.alignCenter", &json!({})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal command signals"); + assert!(!s.take_pending_ime_application_change(), "taking clears it"); + s.run_ime("text.insert", &json!({"text": "x"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "an IME transaction is not an application change"); + s.run("text.insert", &json!({"text": "y"})).unwrap(); + assert!(s.take_pending_ime_application_change()); + } + + /// Unknown and refused IME commands restore the normal origin without signalling an application + /// change, and a nested command inside a macro inherits the IME origin. + #[test] + fn ime_origin_is_restored_and_nested_commands_inherit_it() { + let mut s = Session::new(Document::from_text("hello")); + s.run("tools.macros", &json!({"define": {"name": "centre", "steps": [{"command": "para.alignCenter", "params": {}}]}})).unwrap(); + s.take_pending_ime_application_change(); + + // Unknown and refused commands restore the normal application origin; the failed run + // itself leaves no application-change signal. + assert!(s.run_ime("no.such.command", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "an unknown IME command is not an application change"); + assert!(s.run_ime("equation.backspace", &json!({})).is_err()); + assert!(!s.take_pending_ime_application_change(), "a refused IME command is not an application change"); + + // A nested `run` inherits the IME origin from `run_ime`; the same macro run normally is + // an application change. + s.run_ime("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(!s.take_pending_ime_application_change(), "the nested command inherits the IME origin"); + s.run("tools.macros", &json!({"run": "centre"})).unwrap(); + assert!(s.take_pending_ime_application_change(), "a normal macro run is application-origin"); + } +} diff --git a/crates/engine/src/session/layout.rs b/crates/engine/src/session/layout.rs new file mode 100644 index 000000000..197fdb431 --- /dev/null +++ b/crates/engine/src/session/layout.rs @@ -0,0 +1,210 @@ +use super::doc_state::{Selection, TypedWord, typed_range}; +use super::locale::SYSTEM_LOCALE; +use super::snapshot_types::LayoutEntry; +use super::view::DRAFT_WIDTH; +use super::*; + +impl Session { + /// The current layout (recomputed when the document or view changed). + pub fn layout(&mut self) -> Arc { + let typing = self.typing_word(); + self.layout_with(typing) + } + + /// [`Session::layout`] with `typing` as the word being typed. + fn layout_with(&mut self, typing: Option) -> Arc { + let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode }; + let width = match mode { + ViewMode::Read => self.view.read_width, + ViewMode::Web => self.view.web_width, + _ => DRAFT_WIDTH, + }; + let height = if self.view.read_mode { self.view.read_height } else { 0.0 }; + let spacing = if self.view.read_mode { self.view.read_line_spacing.map(f32::to_bits) } else { None }; + // Deletions in balloons depend on the view (the Comments pane, Read Mode, side by side), so + // a view change that moves them in or out of the text lays it out again. + let hide_deleted = !self.view.show_markup; + let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); + let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); + let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); + let (rev, proofing, numeral) = (self.rev, self.view.proofing, self.view.numeral); + let fits = |e: &Option| { + e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { + *r == rev + && (*w == width || mode == ViewMode::Print) + && *h == height + && *m == mode + && *sp == spacing + && *pf == proofing + && *n == numeral + && *hd == deleted + && *o == outline + && *t == typing + && *sh == shown + }) + }; + if !fits(&self.layout[0]) && fits(&self.layout[1]) { + self.layout.swap(0, 1); + } + if fits(&self.layout[0]) + && let Some(e) = &self.layout[0] + { + return e.5.clone(); + } + let (fn_name, fn_path) = self.resolved_filenames(); + let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); + let opts = LayoutOptions { + view: mode, + web_width: width, + read_height: height, + read_line_spacing: spacing.map(f32::from_bits), + show_hidden: shown.0, + hide_deleted, + hide_marked: deleted.1, + deletions_in_balloons: deleted.2, + proofing: self.view.proofing, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), + comment_balloons: false, + field_codes: shown.1, + outline: outline.clone(), + proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, + typing_word: typing, + filename: filename.clone(), + filepath: filepath.clone(), + }; + let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); + let [newest, _] = std::mem::take(&mut self.layout); + self.layout = [ + Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), + newest, + ]; + l + } + + /// The recorded word while the caret and the word are as typing left them; else drops it. + fn typing_word(&mut self) -> Option { + let t = self.typing.as_ref()?; + let p = if self.sel.is_collapsed() && self.sel.focus == t.at { self.doc.para_at(&t.at) } else { None }; + match p { + Some(p) if typed_range(&p.text, t.at.off) == Some((t.start, t.end)) => { + Some(wordcraft_layout::TypingWord { rev: p.rev, start: t.start, end: t.end }) + } + _ => { + self.typing = None; + None + } + } + } + + /// After a successful command: a `text.*` edit at the caret records the word being typed; + /// any other caret move ends it. + pub(super) fn note_typing(&mut self, id: &str, before: Option<&Document>, sel_before: &Selection) { + let at = &self.sel.focus; + let edited = || before.is_some_and(|d| d.para_at(at).map(|p| &p.text) != self.doc.para_at(at).map(|p| &p.text)); + if id.starts_with("text.") && edited() { + self.typing = self.word_being_typed(); + } else if self.sel != *sel_before { + self.typing = None; + } + } + + /// The word the collapsed caret ends or sits in, as [`TypedWord`]. + fn word_being_typed(&self) -> Option { + if !self.sel.is_collapsed() { + return None; + } + let at = self.sel.focus.clone(); + let (start, end) = typed_range(&self.doc.para_at(&at)?.text, at.off)?; + Some(TypedWord { at, start, end }) + } + + /// The page as command `id` would leave it, for a live preview (Insert › Table's grid, #3931): + /// the command runs on a copy of the document, with this caret and view, and the copy is laid + /// out. The copy comes back with its layout, which is drawn from it: the layout's paragraph + /// paths name the copy's paragraphs, not this document's, and so is the copy's selection, + /// which is where the caret would be. The document, the selection, the undo history and Repeat + /// are untouched. `None` when the command fails or isn't allowed here. + pub fn preview_layout(&mut self, id: &str, params: &Value) -> Option<(Document, Selection, Arc)> { + // The word being typed keeps going without a squiggle on the preview: the command moves + // the copy's caret off it and may split its paragraph, so it is found again by its place. + let typing = self.typing_word(); + let at = self.sel.focus.clone(); + let mut copy = Session::with_registry(self.doc.clone(), self.registry.clone()); + copy.sel = self.sel.clone(); + copy.column = self.column.clone(); + copy.widened = self.widened.clone(); + copy.view = self.view.clone(); + copy.prefs = self.prefs.clone(); + copy.path = self.path.clone(); + copy.file_name = self.file_name.clone(); + copy.author = self.author.clone(); + copy.last_highlight = self.last_highlight; + copy.system_region = self.system_region.clone(); + copy.editing_lang = self.editing_lang.clone(); + // The copy shares the paragraph layouts, so only what the command changed is laid out. + std::mem::swap(&mut copy.cache, &mut self.cache); + let layout = copy.run(id, params).is_ok().then(|| { + let typing = typing.and_then(|t| { + let p = copy.doc.para_at(&at)?; + (t.end <= p.len()).then_some(wordcraft_layout::TypingWord { rev: p.rev, ..t }) + }); + copy.layout_with(typing) + }); + std::mem::swap(&mut copy.cache, &mut self.cache); + layout.map(|l| (copy.doc, copy.sel, l)) + } + + /// A layout for output (PDF, images, print): no proofing marks, print view. + pub fn export_layout(&self) -> Arc { + self.export_layout_with(false) + } + + /// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup + /// area with the comments in balloons. + pub fn export_layout_with(&self, markup: bool) -> Arc { + let (fn_name, fn_path) = self.resolved_filenames(); + let opts = LayoutOptions { + view: ViewMode::Print, + comment_balloons: markup && self.prefs.markup.comments, + numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), + filename: Some(Arc::from(fn_name.as_str())), + filepath: Some(Arc::from(fn_path.as_str())), + ..Default::default() + }; + 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 = Default::default(); + self.cache.clear(); + } + + /// Set the language new documents are written in ([`Session::editing_lang`]; `None` or a + /// malformed tag: US English). A document nobody has touched yet — untitled, unchanged, no + /// undo steps, no text — switches too, so the blank document a session starts with follows + /// the saved proofing default, or the interface language when none is saved. Nothing else + /// changes and no undo step is added. + pub fn set_editing_language(&mut self, tag: Option<&str>) { + let tag = self + .prefs + .default_language + .as_deref() + .and_then(wordcraft_proof::lang::normalize_tag) + .or_else(|| tag.and_then(wordcraft_proof::lang::normalize_tag)); + if self.editing_lang == tag { + return; + } + self.editing_lang = tag; + let untouched = self.path.is_none() && !self.dirty && self.history.is_empty() && self.redo.is_empty(); + if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { + self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); + self.rev = self.rev.wrapping_add(1); + // Not an edit: the document is as untouched as it was. + if self.made_rev == self.rev.wrapping_sub(1) { + self.made_rev = self.rev; + } + self.relayout(); + } + } +} diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs new file mode 100644 index 000000000..d1df0915d --- /dev/null +++ b/crates/engine/src/session/lifecycle.rs @@ -0,0 +1,173 @@ +use super::doc_state::MAX_UI_REQUESTS; +use super::prefs::Prefs; +use super::view::ViewState; +use super::*; + +impl Session { + /// Queue a request for the front end. A headless session never drains the queue, so only + /// the newest [`MAX_UI_REQUESTS`] are kept. + pub fn push_ui_request(&mut self, req: Value) { + if self.ui_requests.len() >= MAX_UI_REQUESTS { + self.ui_requests.remove(0); + } + self.ui_requests.push(req); + } + + /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, + /// with `removed` (words just taken out) left out of the merge with the file. A failed save + /// keeps the words in memory and never fails the command (#40), as does a file outside the + /// automation roots in force: it is read, then replaced beside itself (#3737). + pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { + let Some(path) = self.user_dict_path.as_deref() else { return }; + let name = path.to_string_lossy(); + if let Err(e) = crate::file_access::check_read(&name) + .and_then(|()| crate::file_access::check_write(&name)) + .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) + { + log::warn!("custom dictionary: save failed: {e}"); + } + } + + /// Start an editing session at the document beginning with empty history and + /// default view, preferences and a yellow highlighter independent of document undo. + pub fn new(doc: Document) -> Self { + Self::with_registry(doc, Arc::new(crate::cmd::registry())) + } + + /// Initialise session defaults while sharing the immutable command registry with previews. + pub(super) fn with_registry(doc: Document, registry: Arc) -> Self { + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + ime_body_epoch, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = DocState::new(doc, 1, ViewState::default()); + Session { + doc, + sel, + view, + pending, + last_highlight: wordcraft_doc::props::Highlight::Yellow, + path, + file_name, + user_dict_path: None, + dirty, + clipboard: None, + pending_move, + clipboard_text: String::new(), + clipboard_html: String::new(), + clip_history: Default::default(), + find, + goal_x, + page_hint, + registry, + author: "WordCraft User".into(), + initials: String::new(), + painter: None, + status: String::new(), + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap, + rev, + document_id, + untitled, + made_rev, + protect_facts: std::sync::Mutex::new(None), + ime_body_epoch, + ime_state_revision: None, + ime_state_seen: None, + ime_edit_outcome: ImeEditOutcome::default(), + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: false, + pending_ime_application_change: false, + ime_origin: false, + cache, + layout, + typing, + originals, + picture_looks, + last_command: None, + recording: None, + macros: Default::default(), + versions, + building_blocks: Default::default(), + autosave, + bib_style, + merge, + ui_requests: Vec::new(), + math, + math_latex: false, + math_normal_text: false, + also_selected, + prefs: Prefs::default(), + system_region: Default::default(), + keymap: crate::KeyMap::default(), + ribbon: crate::RibbonLayout::default(), + editing_lang: None, + read_aloud: Default::default(), + password, + #[cfg(not(target_arch = "wasm32"))] + recovery: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, + column, + column_mode, + widened, + edit_marks, + go_back_step, + others: Vec::new(), + order: vec![document_id], + untitled_count: 1, + } + } +} diff --git a/crates/engine/src/session/locale.rs b/crates/engine/src/session/locale.rs new file mode 100644 index 000000000..2a1c622c5 --- /dev/null +++ b/crates/engine/src/session/locale.rs @@ -0,0 +1,15 @@ +use super::*; + +/// The system locale (BCP 47 tag) the shell found at startup, for Numeral › System. +pub(super) static SYSTEM_LOCALE: OnceLock = OnceLock::new(); + +/// Tell the engine the system locale (the shell calls this once at startup; layout never reads +/// the environment). Headless sessions without it show Western digits for Numeral › System. +pub fn set_system_locale(tag: &str) { + let _ = SYSTEM_LOCALE.set(tag.to_string()); +} + +/// The system locale [`set_system_locale`] recorded, if the shell set one. +pub(crate) fn system_locale() -> Option<&'static str> { + SYSTEM_LOCALE.get().map(String::as_str) +} diff --git a/crates/engine/src/session/prefs.rs b/crates/engine/src/session/prefs.rs new file mode 100644 index 000000000..0ed8a92d4 --- /dev/null +++ b/crates/engine/src/session/prefs.rs @@ -0,0 +1,102 @@ +use super::*; + +/// Editing preferences that persist between runs (the front end saves and restores them). +/// File › Options sets most of them (`file.options`). +#[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, + /// File › Print / Export: Print Markup. PDFs, prints and page images show comments in + /// balloons beside each page and tracked changes as marked up; HTML, Markdown and text + /// copies end with the comments (`file.printMarkup`). + pub print_markup: bool, + /// Language › Detect language automatically (per user, as in Word). + pub detect_language: bool, + /// Set Proofing Language › Set As Default: overrides the interface language for future + /// blank documents and plain text. Older preferences keep following the interface. + #[serde(deserialize_with = "lenient_default_language")] + pub default_language: Option, + /// Typing over a selection replaces it; off, the typed text goes in front of it. + pub typing_replaces_selection: bool, + /// The Insert key switches overtype on and off. + pub insert_key_overtype: bool, + /// Overtype: typed characters replace the ones after the caret. + pub overtype: bool, + /// The unit rulers and dialogs show lengths in, when the user chose one (File › Options › + /// Advanced › Measurement units); `None` follows the region ([`Session::units`]). Saved under + /// a new key; an older `units` value other than inches (the old default) carries over on load. + #[serde(rename = "measurementUnits")] + pub units: Option, + /// The region the user chose for new documents, units and the decimal separator (a locale + /// tag like `pl-PL`, File › Options › Advanced › Region); `None` follows the system. + pub region: Option, + /// A folder searched for Hunspell dictionaries before the system locations (File › Options › + /// Proofing, #1063): an app started from Finder or the Dock doesn't see + /// `WORDCRAFT_DICTIONARIES`. `None` searches the system locations only. + pub dictionaries_folder: Option, + /// The user templates folder File › New lists personal templates from and Save as Word + /// Template starts in (File › Options › Advanced › File locations, #2852). `None`: the app's + /// own `Templates` folder in its settings folder. + pub user_templates_folder: Option, + /// Formatting marks shown even while Show/Hide ¶ is off. + pub marks: wordcraft_layout::display::FormattingMarks, + /// Mark grammar errors as you type (with Check spelling as you type on). + pub mark_grammar: bool, + /// Check spelling as you type (File › Options › Proofing). The live value is + /// `ViewState::proofing`; this is the copy that is saved and restored (#1792). + pub check_spelling: bool, + pub ignore_uppercase: bool, + pub ignore_numbers: bool, + pub ignore_internet: bool, + /// AutoCorrect Options: replacements, capitalization and AutoFormat As You Type (per user, as + /// in Word). A damaged saved value reads as the defaults. + #[serde(deserialize_with = "crate::cmd::tools::lenient_autocorrect")] + pub autocorrect: crate::cmd::tools::AutoCorrectPrefs, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + count_notes: true, + markup: Default::default(), + print_markup: false, + typing_replaces_selection: true, + insert_key_overtype: false, + overtype: false, + units: Default::default(), + region: None, + dictionaries_folder: None, + user_templates_folder: None, + marks: Default::default(), + mark_grammar: true, + check_spelling: true, + ignore_uppercase: true, + ignore_numbers: true, + ignore_internet: true, + detect_language: true, + default_language: None, + autocorrect: Default::default(), + } + } +} + +impl Prefs { + /// What the spelling check skips. + pub fn spelling(&self) -> wordcraft_proof::Options { + wordcraft_proof::Options { + ignore_uppercase: self.ignore_uppercase, + ignore_numbers: self.ignore_numbers, + ignore_internet: self.ignore_internet, + } + } +} + +/// Ignore malformed saved language tags without discarding the other preferences. +fn lenient_default_language<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(value.as_str().and_then(wordcraft_proof::lang::normalize_tag)) +} diff --git a/crates/engine/src/session/query.rs b/crates/engine/src/session/query.rs new file mode 100644 index 000000000..0dbe8df5e --- /dev/null +++ b/crates/engine/src/session/query.rs @@ -0,0 +1,290 @@ +use super::doc_state::{CellRange, EDIT_MARKS, Selection}; +use super::*; + +impl Session { + /// 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. + pub(super) 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)]> { + // Cells that are one run of the text are an ordinary stream selection. + self.column.as_ref().filter(|c| c.sel == self.sel && !c.cells.as_ref().is_some_and(|r| r.stream)).map(|c| c.segments.as_slice()) + } + + /// The whole table cells selected, if the selection is that (see [`CellRange`]). + pub fn cell_range(&self) -> Option<&CellRange> { + self.column.as_ref().filter(|c| c.sel == self.sel).and_then(|c| c.cells.as_ref()) + } + + /// The selection as it was made: where a drag, Shift+click or Shift+arrows started and where + /// the caret went, even when Word's table rule widened it to whole cells or rows (`sel` holds + /// those). + pub fn sel_ends(&self) -> &Selection { + self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) + } + + pub fn selects_whole_story(&self) -> bool { + let st = self.sel.focus.story; + let (a, b) = self.sel.ordered(); + a == self.doc.start_of(st) && b == self.doc.end_of(st) + } + + /// Whether the caret is in a header or footer. + pub fn in_header_footer(&self) -> bool { + let StoryRef::Part(id) = self.sel.focus.story else { return false }; + self.doc.parts.get(&id).is_some_and(|p| matches!(p.kind, PartKind::Header | PartKind::Footer)) + } + + /// 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 + } + + /// How many times the body was replaced under this [`Self::document_id`]. + /// + /// Front ends use it as the body's IME replacement epoch and to tell a body swap from an + /// ordinary edit; it advances for `document.setText`, a version restore, a compare and + /// Undo/Redo, never for typing. + pub fn ime_body_epoch(&self) -> u64 { + self.ime_body_epoch + } + + /// Note that a command replaced the document or its whole body under this document id. + /// + /// The counter fails closed: if it were ever exhausted (unreachable in practice), the + /// document owner id advances too, so a cached IME context still cannot be reused. + pub fn note_body_replaced(&mut self) { + self.ime_body_epoch = match self.ime_body_epoch.checked_add(1) { + Some(epoch) => epoch, + None => { + self.document_id = self.document_id.saturating_add(1); + u64::MAX + } + }; + } + + 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(&[]) + } + + /// Whether the markup area beside the page can show balloons: markup shown in Print Layout + /// (not Read Mode or pages side by side), comments not listed in the Comments pane instead, + /// and Track Changes Options not set to show every revision inline. + pub fn balloons_shown(&self) -> bool { + let v = &self.view; + v.show_markup + && !v.comments_pane + && !v.read_mode + && !v.multi_page + && v.mode == ViewMode::Print + && self.prefs.markup.balloons != wordcraft_layout::display::BalloonMode::Inline + } + + /// Show revisions in balloons, with the balloons on screen: tracked deletions in the body leave + /// the text for balloons in the markup area. Where no balloons can be shown they stay inline, + /// as in Word; so do deletions in headers, footers, notes and text boxes, which get no balloons. + pub fn revisions_in_balloons(&self) -> bool { + let m = &self.prefs.markup; + self.balloons_shown() && m.balloons == wordcraft_layout::display::BalloonMode::Revisions && m.insertions_deletions + } + + /// Initials for new comments: the ones set in File › Options, else the user name's. + pub fn user_initials(&self) -> String { + let set = self.initials.trim(); + if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() } + } + + /// The document's file name and full path for fields (`FILENAME`) and layout context. + /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default + /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used + /// as a filename fallback (#3449). + pub fn resolved_filenames(&self) -> (String, String) { + if let Some(path) = &self.path { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string()); + let full = path.to_string_lossy().to_string(); + (name, full) + } else if let Some(name) = &self.file_name { + (name.clone(), name.clone()) + } else { + let name = untitled_name(self.untitled); + (name.clone(), name) + } + } + + /// The region in effect (#838): the one the user chose ([`Prefs::region`]), else the + /// system's. + pub fn region(&self) -> wordcraft_geom::Region { + match self.prefs.region.as_deref() { + Some(tag) => wordcraft_geom::Region::from_tag(tag), + None => self.system_region.clone(), + } + } + + /// The English of the region in effect (`en-GB` for a British region), or `None` when the + /// region's language isn't English, it names no territory, or it is the United States (US + /// English is the default anyway). An English interface writes new documents in it, as Word + /// does on a British or Canadian system (#1045). + pub fn region_english(&self) -> Option { + let r = self.region(); + (r.language == "en" && !r.territory.is_empty() && r.territory != "US") + .then(|| format!("en-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + } + + /// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan + /// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's + /// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the + /// writer's Spanish from the first word (#3360). + pub fn region_spanish(&self) -> String { + let r = self.region(); + (matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty()) + .then(|| format!("es-{}", r.territory)) + .and_then(|t| wordcraft_proof::lang::normalize_tag(&t)) + .unwrap_or_else(|| "es-ES".into()) + } + + /// The unit rulers and dialogs show lengths in: the user's choice, else the region's. + pub fn units(&self) -> wordcraft_geom::Unit { + self.prefs.units.unwrap_or_else(|| self.region().units()) + } + + /// `pt` in [`Session::units`], written the region's way (`2,54 cm` in Poland). + pub fn format_length(&self, pt: f32) -> String { + self.region().format(self.units(), pt) + } + + /// A new blank document (the Normal template) on the region's paper: Letter in the US and + /// other Letter regions, A4 elsewhere (#838). Margins stay 2.54 cm (1") all round. + pub fn blank_document(&self) -> Document { + blank_document(&self.region()) + } + + /// 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: the caret's, except run direction. New text + /// takes its direction from its own letters, as on screen, so Latin typed or inserted into + /// reopened Hebrew isn't saved as right-to-left; the DOCX writer marks right-to-left pieces + /// itself (#1543). The bidi language stays (it only applies to complex-script text). + /// Over a selection, as in Word, typing takes the first selected character's formatting, + /// whichever way the selection was made (#2879). + pub fn typing_props(&self) -> CharProps { + let (a, _) = self.sel.ordered(); + let first = match self.doc.para_at(&a) { + Some(p) if self.pending.is_none() && !self.sel.is_collapsed() && a.off < p.len() => Some(p.props_of_char(a.off)), + _ => None, + }; + let mut c = match first { + Some(props) => { + // The selection is replaced, so the new text isn't part of a link or a note number. + let mut out = if props.link.is_some() { props.unlinked() } else { props.clone() }; + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out + } + None => self.caret_props(), + }; + c.rtl = None; + c + } + + /// The caret's formatting, as Reveal Formatting shows it. + pub fn caret_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; + // Text typed next to a symbol is in the text's font, not the symbol's (`w:sym`). + let mut out = (if props.link.is_some() && !inside_link { props.unlinked() } else { props.clone() }).without_symbol(); + // A footnote or endnote number is raised by its character style; text typed after it is body text. + if matches!(out.style.as_deref(), Some("FootnoteReference" | "EndnoteReference")) { + out.style = None; + } + out + } + + /// 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 { + // A column block or whole cells that aren't one run of the text: each piece's own text, + // a line each, so the status bar and Word Count count those cells only. + if let Some(segs) = self.column_segments() { + return segs.iter().map(|(a, b)| self.doc.copy_range(a.min(b), a.max(b)).plain_text()).collect::>().join("\n"); + } + let (a, b) = self.sel.ordered(); + self.doc.copy_range(&a, &b).plain_text() + } +} + +/// A new blank document on `region`'s paper (see [`Session::blank_document`]). +pub fn blank_document(region: &wordcraft_geom::Region) -> Document { + let mut d = Document::new(); + let (_, w, h) = region.paper(); + d.last_section.page_w = w; + d.last_section.page_h = h; + d +} diff --git a/crates/engine/src/session/run.rs b/crates/engine/src/session/run.rs new file mode 100644 index 000000000..9d5456461 --- /dev/null +++ b/crates/engine/src/session/run.rs @@ -0,0 +1,389 @@ +use super::doc_state::{ColumnBlock, Selection}; +use super::snapshot_types::RunSnapshot; +use super::*; + +impl Session { + /// Whether the command changed nothing: the document, the selection, the formatting + /// waiting at the caret and the column selection are as they were before it ran. + /// + /// A command that changes nothing is not an edit: it adds no undo step, marks no + /// unsaved changes, forces no relayout and leaves the redo stack alone (#23). + fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option, pending: &Option) -> bool { + doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending + } + + /// 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 { + // The document the command starts in, and the first id a document it opens can have. + let home = self.document_id; + let mark = document_id_mark(); + // Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled, + // protected) leave it at the default. + self.ime_edit_outcome = ImeEditOutcome::default(); + // A rolled-back transaction must not leave a successful application-change signal from a + // nested command that ran before the failure. + let pending_application_change_before = self.pending_ime_application_change; + let join = std::mem::take(&mut self.join_next); + // The equation replacement promise is not a generic undo join: only the command that + // actually types into the equation may consume it. Any other command discards it. + let equation_join = std::mem::take(&mut self.equation_join_next); + // The body replacement promise is likewise scoped: only the `text.insert` at the + // selection the deletion left may consume it. + let ime_text_join = self.ime_text_join.take(); + let reg = self.registry.clone(); + let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; + let join = join + || (equation_join && matches!(id, "equation.type" | "equation.backspace")) + || (id == "text.insert" && ime_text_join.as_ref().is_some_and(|sel| sel == &self.sel)); + if let Some(why) = (spec.enabled)(self) { + return Err(CmdError::Disabled(format!("{id}: {why}"))); + } + // Restrict Editing: refused outright, or allowed as long as it only edits the exceptions. + let guard = if spec.mutates { crate::cmd::protect::check_before(self, id, params)? } else { false }; + // Quick Parts aren't part of the document, so a refused edit puts them back itself (the Spike). + let blocks_before = guard.then(|| self.building_blocks.clone()); + if spec.mutates && self.doc.settings.protection.as_deref() == Some("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.")); + // A password never lands in a recorded macro or Repeat. + let kept = || { + let mut p = params.clone(); + if let Some(m) = p.as_object_mut() { + m.remove("password"); + } + p + }; + if record && let Some((_, steps)) = self.recording.as_mut() { + steps.push((id.to_string(), kept())); + } + if spec.mutates && !matches!(id, "edit.undo" | "edit.redo" | "edit.repeat") { + self.last_command = Some((id.to_string(), kept())); + } + // 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 ime_epoch_before = spec.mutates.then_some(self.ime_body_epoch); + // Commands may leave an equation (a whole-body replacement) even after they fail; put + // the editing session back with the document. + let math_before = spec.mutates.then(|| self.math.clone()); + let before_doc: Option = 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 + }; + // The formatting waiting at the caret (⌘B with nothing selected): an undo step restores + // it, so a command that only changes it is an edit. + let pending_before = spec.mutates.then(|| self.pending.clone()); + // Which document is open. A command that replaces the document (a mail merge) opens a + // new one, with a new undo history: it is not an edit of this document, whatever the + // new document holds. + let document_id_before = self.document_id; + // Whether the document has unsaved changes, and the revision its layout is keyed on: a + // command that changes nothing leaves both as they were, and a failed one leaves no + // trace. + let dirty_before = spec.mutates.then_some(self.dirty); + let rev_restore = spec.mutates.then_some(self.rev); + // These mutating commands can replace the open document and discard its saved versions. + // Keep the versions until the command and the protected-region post-check have both + // succeeded; rejected commands must leave the old document and its snapshots together. + // `mailings.finish` with a path saves the merged copy there and leaves the open + // document alone, so it replaces nothing. + let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels") + || (id == "mailings.finish" && crate::p::str(params, "path").is_none()); + let versions_before = replaces_document.then(|| self.versions.clone()); + let identity_before = replaces_document.then(|| { + ( + self.path.clone(), + self.file_name.clone(), + self.document_id, + self.password.clone(), + self.dirty, + self.bib_style.clone(), + self.merge.preview, + ) + }); + let typing = spec.id == "text.insert" || spec.id == "equation.type"; + if spec.mutates && !typing { + self.typing_open = false; + } + // Whether this command pushed an undo step (a no-op command's step is dropped below, + // leaving its undo group without one). + let mut pushed = false; + if spec.mutates { + let label = if typing { "Typing" } else { spec.label }; + // A joined command merges into its group's step; a group whose first command + // changed nothing has none, so the next command in it starts one (#4001). + if !join || self.history.is_empty() || !self.join_anchor { + pushed = 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; + } + // Only the paste right after a tracked cut makes it a move. + if (spec.mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo")) && !matches!(id, "edit.cut" | "edit.paste" | "edit.pasteSpecial") + { + self.pending_move = None; + } + let sel_before = self.sel.clone(); + // A rejected command must not persist a new proofing default, including a rejection + // by the protected-region check after its handler has succeeded. Undo stays document-only. + let default_language_before = self.prefs.default_language.clone(); + let run = spec.run; + let mutates = spec.mutates; + let ime_caret_before = self.current_ime_caret_state(); + let rev_before = self.rev; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| crate::cmd::column::dispatch(self, id, mutates, run, params))); + let mut result = match result { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + Err(CmdError::Failed(format!("{id} panicked: {msg}"))) + } + }; + if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { + return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before, pending_application_change_before); + } + // Restrict Editing: outside the exceptions nothing may change. + if result.is_ok() + && guard + && let Some((before, ..)) = &before_doc + && !crate::cmd::protect::outside_unchanged(before, &self.doc, &self.author) + { + result = Err(CmdError::Disabled(format!("{id}: only the editable regions of this protected document can change"))); + } + match &result { + Ok(_) => { + let mut no_op = false; + 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.doc.ensure_nonempty(); + self.doc.prune_text_boxes(); + // Whether the command left the document, the selection and the formatting + // waiting at the caret as they were. A command that replaces the document — + // a mail merge, a comparison — is not an edit of this one, whatever the new + // document holds: `replaces_document` names the commands that may replace + // it, and `document_id` catches any other that does (through + // `set_document`, which starts a new history the old one must not overwrite). + let unchanged = |s: &Session| match (&before_doc, &pending_before) { + (Some((doc, sel, _, _, _, _, column)), Some(pending)) => { + !replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending) + } + _ => false, + }; + let changed = !unchanged(self); + if changed { + // The layout the connectors are rerouted against is the new one. + self.touch(); + } + // Glued connectors follow the shapes they join. + let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self); + if rerouted { + self.touch(); + } + // A command that changed nothing is not an edit (#23): it adds no undo + // step, marks no unsaved changes, forces no relayout and leaves the redo + // stack alone. What it left behind is compared with what it started from, + // after the reroute: a reroute that moved a connector is an edit of its own. + no_op = !changed && !rerouted && unchanged(self); + if no_op { + // The step the dispatch pushed is not an edit: put the stacks back as + // they were, so the redo stack survives and no step is evicted. The + // group it belonged to is left without a step of its own, so the next + // command in it starts one. + if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc { + self.history = history.clone(); + self.redo = redo.clone(); + self.typing_open = *typing_open; + self.undo_evicted = *evicted; + } + if let Some(dirty) = dirty_before { + self.dirty = dirty; + } + if let Some(rev) = rev_restore { + let touched = self.rev; + self.restore_rev(rev); + crate::cmd::draw::rekey_lasso(self, touched, rev); + } + if pushed { + self.join_anchor = false; + } + } + } + self.clamp_selection(); + let mut ime_outcome = self.ime_edit_outcome; + if ime_outcome.content_changed { + ime_outcome.state_changed = true; + } + // An unclassified mutating command is conservatively a state change. It can make + // a frame Other, but it can never mint a receipt because `classified` is false. + // A command that changed nothing is not an edit (#23), so it is not an IME + // state change either: otherwise Backspace at the document start would still + // signal an application change and move the settled IME revision. + if !ime_outcome.classified && mutates && !no_op { + ime_outcome.state_changed = true; + } + ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before; + self.ime_edit_outcome = ime_outcome; + if ime_outcome.state_changed { + let caret = self.current_ime_caret_state(); + self.ime_state_seen = Some(caret); + self.reset_ime_state_revision(); + } + if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) { + self.pending_ime_application_change = true; + } + if sel_before.focus.story == StoryRef::Body && self.in_header_footer() { + self.hf_return = Some(sel_before.clone()); + } + // A selection command that moves the insertion point ends the typing group, like + // caret movement: typing, Select, typing are two undo steps (#419). + if !spec.mutates && self.sel != sel_before { + self.typing_open = false; + } + if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") { + self.note_edit(); + } + // Outline view: collapsed headings follow edits; the caret stays where it shows. + crate::cmd::outline::after_command(self, &sel_before, self.rev != rev_before); + // Extra selected objects last until something else is edited or selected; formatting + // them keeps them selected, as in Word. + if !matches!(id, "select.addObject" | "arrange.ungroup" | "shape.fill" | "shape.effects") && (spec.mutates || self.sel != sel_before) + { + self.also_selected.clear(); + } + self.note_typing(id, before_doc.as_ref().map(|b| &b.0), &sel_before); + } + Err(e) => { + self.ime_edit_outcome = ImeEditOutcome::default(); + self.pending_ime_application_change = pending_application_change_before; + self.autocap = None; + self.prefs.default_language = default_language_before; + if let Some(epoch) = ime_epoch_before { + self.ime_body_epoch = epoch; + } + if let Some(math) = math_before { + self.math = math; + } + if let Some(versions) = versions_before { + self.versions = versions; + } + if let Some((path, file_name, document_id, password, dirty, bib_style, merge_preview)) = identity_before { + self.path = path; + self.file_name = file_name; + self.document_id = document_id; + self.password = password; + self.dirty = dirty; + self.bib_style = bib_style; + self.merge.preview = merge_preview; + } + 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; + // Its step is gone with them: its undo group has none to merge into. + if pushed { + self.join_anchor = false; + } + } + if let Some(dirty) = dirty_before { + self.dirty = dirty; + } + if let Some(rev) = rev_restore { + self.restore_rev(rev); + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } + + /// The end of [`Session::run`] for a command that ended up in another document than it + /// started in (it opened, made, switched to or closed one) or that failed after opening one. + /// The checks that compare the document before and after the command don't apply. + /// Succeeded: the first document loses the undo step made for the command when its text is + /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is + /// active and as it was. + fn finish_elsewhere( + &mut self, + home: u64, + mark: u64, + result: Result, + before_doc: Option, + blocks_before: Option>, + default_language_before: Option, + pending_application_change_before: bool, + ) -> Result { + // Another document is a state change the input method has to see, never an IME edit. + self.ime_edit_outcome = ImeEditOutcome::default(); + match &result { + Ok(_) => { + self.put_back_parked(home, before_doc); + self.clamp_selection(); + self.ime_state_seen = Some(self.current_ime_caret_state()); + self.reset_ime_state_revision(); + if !self.ime_origin { + self.pending_ime_application_change = true; + } + } + Err(e) => { + self.pending_ime_application_change = pending_application_change_before; + self.autocap = None; + self.prefs.default_language = default_language_before; + if self.is_open(home) { + let _ = self.switch_to(home); + let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); + for d in opened { + let _ = self.close_document(d); + } + 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; + self.undo_evicted = ev; + self.column = c; + } + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } +} diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs new file mode 100644 index 000000000..91f8c1752 --- /dev/null +++ b/crates/engine/src/session/snapshot.rs @@ -0,0 +1,287 @@ +use super::doc_state::{MAX_UNDO, Selection, doc_bib_style}; +use super::snapshot_types::{EditSnapshot, SNAPSHOT_HEAD}; +use super::*; + +impl Session { + /// Replace the document (open/new). + pub fn set_document(&mut self, doc: Document) { + self.autocap = None; + let replaced = self.document_id; + self.document_id = next_document_id(); + if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { + *at = self.document_id; + } + self.note_body_replaced(); + self.bib_style = doc_bib_style(&doc); + self.doc = doc; + self.doc.ensure_nonempty(); + self.password = None; + self.file_name = None; + // The old document's equation is gone: its editing session cannot carry over. + self.math = None; + self.sel = Selection::caret(self.doc.start_of(StoryRef::Body)); + self.pending = None; + self.typing = None; + self.also_selected.clear(); + self.column = None; + self.column_mode = false; + self.widened = None; + self.edit_marks.clear(); + self.new_header_parts.clear(); + self.hf_return = None; + self.go_back_step = 0; + self.view.outline.collapsed.clear(); + // A copy the replaced document kept stays in the recovery list. + #[cfg(not(target_arch = "wasm32"))] + { + self.recovery_slot = Default::default(); + } + self.reset_history(); + self.clear_versions(); + self.touch(); + self.dirty = false; + self.made_rev = self.rev; + 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, the pending `join_next_undo`, + /// `join_next_equation_edit` and body replacement promises, whether the typing group has a + /// step of its own, 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`), clearing it (`file.open` into + /// a blank document) or switching documents 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: _, + last_highlight: _, + path: _, + file_name: _, + // The custom dictionary file, like the file path: host wiring, not an edit. + user_dict_path: _, + dirty, + clipboard: _, + // Like the clipboard: what the next paste can make a move of. + pending_move: _, + clipboard_text: _, + clipboard_html: _, + clip_history: _, + find: _, + goal_x: _, + page_hint: _, + registry: _, + author: _, + initials: _, + painter: _, + status: _, + // Headers just created to edit: closing checks they still exist and are empty. + new_header_parts: _, + hf_return: _, + history, + undo_evicted, + redo, + typing_open, + join_next, + equation_join_next, + ime_text_join, + join_anchor, + autocap: _, + rev: _, + protect_facts: _, + cache: _, + layout: _, + // The word being typed, like `column`: valid only while `sel` is where typing left it. + typing: _, + originals: _, + picture_looks: _, + last_command: _, + recording: _, + macros: _, + versions: _, + building_blocks: _, + autosave: _, + bib_style: _, + merge: _, + ui_requests: _, + document_id: _, + // The untitled name and when the document was made: not edits. + untitled: _, + made_rev: _, + // The other open documents: a snapshot covers the active one. + others: _, + order: _, + untitled_count: _, + ime_body_epoch, + // The IME revision and its caret token are process-global evidence, not document + // state: a rollback must not reuse a revision. + ime_state_revision: _, + ime_state_seen: _, + ime_edit_outcome: _, + #[cfg(any(test, feature = "test-support"))] + ime_state_exhausted: _, + pending_ime_application_change: _, + ime_origin: _, + read_aloud: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + // A preference for new documents, like `prefs`: not part of this document. + editing_lang: _, + // 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: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, + also_selected: _, + // Column selection and a widened selection's ends, like `sel`'s shape: valid only while + // `sel` matches them. + column: _, + column_mode: _, + widened: _, + // 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, + equation_join_next: *equation_join_next, + ime_text_join: ime_text_join.clone(), + join_anchor: *join_anchor, + dirty: *dirty, + ime_body_epoch: *ime_body_epoch, + } + } + + /// 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, + equation_join_next, + ime_text_join, + join_anchor, + dirty, + ime_body_epoch, + } = 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.autocap = None; + 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.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; + self.ime_body_epoch = ime_body_epoch; + self.join_anchor = join_anchor; + 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, + } + } + } + + /// Why command `id` can't run now, for greying it out: its own `enabled` check, then what + /// Restrict Editing refuses outright. `None` when it can run (or doesn't exist). + pub fn why_disabled(&self, id: &str) -> Option<&'static str> { + let spec = self.registry.get(id)?; + (spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None }) + } + + /// Put the revision back to `rev` after a command that changed nothing or failed. What the + /// command cached at the revision it touched to is dropped: that number comes round again + /// with the next edit, for a different document. + pub(super) fn restore_rev(&mut self, rev: u64) { + if self.rev == rev { + return; + } + self.rev = rev; + for slot in &mut self.layout { + if slot.as_ref().is_some_and(|l| l.0 != rev) { + *slot = None; + } + } + if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) { + self.find.highlights = None; + } + let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner); + if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) { + *facts = None; + } + } + + /// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision. + fn protect_facts(&self) -> crate::cmd::protect::Facts { + let key = (self.document_id, self.rev, self.author.clone()); + let mut cache = self.protect_facts.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((k, f)) = cache.as_ref() + && *k == key + { + return *f; + } + let f = crate::cmd::protect::Facts::of(self); + *cache = Some((key, f)); + f + } +} diff --git a/crates/engine/src/session/snapshot_types.rs b/crates/engine/src/session/snapshot_types.rs new file mode 100644 index 000000000..a0709106b --- /dev/null +++ b/crates/engine/src/session/snapshot_types.rs @@ -0,0 +1,77 @@ +use super::doc_state::{ColumnBlock, Selection, Undo}; +use super::*; + +/// 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, the pending +/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises, +/// whether the group has a step of its own, and the dirty flag. See +/// [`Session::edit_snapshot`]. +pub struct EditSnapshot { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) history_len: usize, + /// `Session::undo_evicted` when the snapshot was taken. + pub(super) 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`]. + pub(super) head: Vec>, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) equation_join_next: bool, + /// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects. + pub(super) ime_text_join: Option, + pub(super) join_anchor: bool, + pub(super) dirty: bool, + /// The body's replacement epoch then: a previewed replacement that is rolled back is not a + /// replacement after all. + pub(super) ime_body_epoch: u64, +} + +/// Undo steps that the commands run between [`Session::edit_snapshot`] and [`Session::restore`] +/// may push out of a full stack and still come back. +pub(super) 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 + } +} + +/// What `run` keeps to take a failed mutating command back: the document, the selection, the +/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column +/// block. +pub(super) type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); + +/// A layout made and what it was made for: (revision, width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field +/// codes shown)). +pub(super) type LayoutEntry = ( + u64, + f32, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (bool, wordcraft_layout::HideDeleted, bool), + NumeralMode, + Option, + (bool, bool), +); + +/// The layouts last made, newest first. Two, so two windows on one document with different +/// views ([`WindowView`]) each find theirs as they draw in turn. +pub(super) type LayoutSlots = [Option; 2]; diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs new file mode 100644 index 000000000..2d23cfd86 --- /dev/null +++ b/crates/engine/src/session/view.rs @@ -0,0 +1,209 @@ +use super::*; + +/// 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, + /// View › Focus (#1678): the document alone on screen, with a slim bar to drop the ribbon down. + pub focus_mode: bool, + /// Full Screen View (#1678): the pages alone on screen. Never on together with `focus_mode`. + #[serde(default)] + pub full_screen_view: 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, + /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). + /// Kept in the user's preferences, not the document. + #[serde(default)] + pub styles_pane_dock: StylesPaneDock, + /// 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, + /// SmartArt Design › Text Pane: the selected SmartArt graphic's items as an editable outline. + #[serde(default)] + pub smart_art_pane: bool, + /// The Accessibility pane (Review › Check Accessibility): the checker's findings. + #[serde(default)] + pub accessibility_pane: bool, + /// The Alt Text pane (Picture/Shape Format › Alt Text): the selected object's or table's alt text. + #[serde(default)] + pub alt_text_pane: bool, + /// The Format Shape pane (Shape Fill › More Gradients…, a shape's Format Shape…): the + /// selected shapes' fill. + #[serde(default)] + pub format_shape_pane: bool, + pub multi_page: bool, + /// One Page or Page Width was chosen last: keep one page per row when a typed or wheel zoom replaces the fit mode. + #[serde(default)] + pub single_column: bool, + /// Zoom to fit: "pageWidth", "onePage", "multiplePages", or empty. + pub fit: String, + /// Web Layout's text width, which the UI keeps at the window's width (#1876); never saved. + /// Draft and Outline don't use it: they keep a fixed 800 pt column. + #[serde(skip, default = "default_web_width")] + pub web_width: f32, + /// Transient viewport metrics for reading pagination; never saved in a document or preferences. + #[serde(skip, default = "default_read_width")] + pub read_width: f32, + #[serde(skip, default = "default_read_height")] + pub read_height: f32, + /// Reader-only layout controls; they are neither document formatting nor saved UI prefs. + /// `None` chooses responsive column width and keeps the document's line spacing. + #[serde(skip)] + pub read_column_width: Option, + #[serde(skip, default = "default_read_zoom")] + pub read_zoom: f32, + #[serde(skip)] + pub read_line_spacing: Option, + /// Read Mode's Page Color: the paper the reading columns are drawn on. + #[serde(skip)] + pub read_page_color: crate::cmd::view::ReadPageColor, + pub dark_mode: bool, + pub show_markup: bool, + pub track_changes_pane: bool, + /// Check spelling and grammar as you type. + pub proofing: bool, + /// How digits look on screen (Word › File › Options › Advanced › Numeral). + #[serde(default)] + pub numeral: NumeralMode, + /// 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, + /// Print Layout › Hide White Space: pages run together, the top and bottom margins (with + /// headers and footers) between pages collapse to a thin line. On screen only; the layout + /// and page count are unchanged. + #[serde(default)] + pub hide_white_space: bool, + /// Outline view: collapsed headings, Show Level, first lines, text formatting. + #[serde(default)] + pub outline: crate::cmd::outline::OutlineState, + /// Developer › Design Mode: every content control shows its frame and title. + #[serde(default)] + pub design_mode: bool, + /// View › Window › Full Screen (F11): the window fills the screen (#980). The front end + /// carries it to the window. + #[serde(default)] + pub full_screen: bool, +} + +/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StylesPaneDock { + #[default] + Right, + Left, + /// A window of its own over the document, which can be moved and resized. + #[serde(rename = "float")] + Floating, +} + +impl StylesPaneDock { + /// `left`, `right` or `float`; anything else is `None`. + pub fn parse(name: &str) -> Option { + match name { + "right" => Some(Self::Right), + "left" => Some(Self::Left), + "float" => Some(Self::Floating), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Right => "right", + Self::Left => "left", + Self::Floating => "float", + } + } +} + +fn on() -> bool { + true +} + +/// The text column of Draft and Outline, and of Web Layout in a headless session. +pub(super) const DRAFT_WIDTH: f32 = 800.0; + +fn default_web_width() -> f32 { + DRAFT_WIDTH +} + +fn default_read_width() -> f32 { + 300.0 +} + +fn default_read_height() -> f32 { + 500.0 +} + +fn default_read_zoom() -> f32 { + 1.0 +} + +impl Default for ViewState { + fn default() -> Self { + ViewState { + zoom: 1.0, + mode: ViewMode::Print, + read_mode: false, + focus_mode: false, + full_screen_view: false, + marks: false, + ruler: true, + gridlines: false, + table_gridlines: true, + nav_pane: false, + styles_pane: false, + styles_pane_dock: StylesPaneDock::Right, + style_inspector: false, + comments_pane: false, + clipboard_pane: false, + smart_art_pane: false, + accessibility_pane: false, + alt_text_pane: false, + format_shape_pane: false, + multi_page: false, + single_column: false, + fit: String::new(), + web_width: default_web_width(), + read_width: default_read_width(), + read_height: default_read_height(), + read_column_width: None, + read_zoom: default_read_zoom(), + read_line_spacing: None, + read_page_color: Default::default(), + dark_mode: false, + show_markup: true, + track_changes_pane: false, + proofing: true, + numeral: NumeralMode::default(), + hide_ink: false, + draw: Default::default(), + field_codes: false, + hide_white_space: false, + outline: Default::default(), + design_mode: false, + full_screen: false, + } + } +}