diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index ee3bb61a9..12de09d5d 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -397,9 +397,21 @@ impl Document { Ok(()) } - /// Apply `f` to the paragraph properties of every paragraph touched by `a..b`. + /// The paragraphs paragraph formatting of `a..b` applies to: [`Self::paths_between`] without + /// the last paragraph when a non-empty range ends at its start (Select Paragraph ends there + /// to include the paragraph mark). A caret applies to its own paragraph (Word, #418). + pub fn para_paths_in(&self, a: &Pos, b: &Pos) -> Vec { + let (a, b) = order(a, b); + let mut paths = self.paths_between(&a, &b); + if b.off == 0 && a.path != b.path && paths.len() > 1 && paths.last() == Some(&b.path) { + paths.pop(); + } + paths + } + + /// Apply `f` to the paragraph properties of every paragraph `a..b` covers ([`Self::para_paths_in`]). pub fn format_paragraphs(&mut self, a: &Pos, b: &Pos, f: &dyn Fn(&mut ParaProps)) -> Result<()> { - for p in self.paths_between(a, b) { + for p in self.para_paths_in(a, b) { let para = self.para_mut(a.story, &p)?; f(&mut para.props); para.touch(); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 674cb981b..5d8d87103 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -371,75 +371,142 @@ fn change_case(s: &mut Session, v: &Value) -> CmdResult { } }); for path in s.doc.paths_between(&a, &b) { - let (from, to, src) = { + // Changed stretches of one run each: (start, end, replacement), in text order. + let groups = { let Some(p) = s.doc.para(a.story, &path) else { continue }; let from = if path == a.path { a.off } else { 0 }; let to = if path == b.path { b.off } else { p.len() }; - (from, to, p.text.get(from..to).unwrap_or("").to_string()) + let Some(src) = p.text.get(from..to) else { continue }; + case_groups(p, from, src, &convert_chars(src, &mode, from == 0)) }; - let new = convert(&src, &mode, from == 0); - if new.len() != src.len() { - // Lengths differ (ß → SS): replace text keeping the first run's formatting. - let para = s.doc.para_mut(a.story, &path)?; - let props = para.props_of_char(from).clone(); - para.delete(from, to)?; - para.insert_text(from, &new, &props)?; - } else { - let para = s.doc.para_mut(a.story, &path)?; - if let Some(slice) = para.text.get(from..to) - && slice.len() == new.len() - { - para.text.replace_range(from..to, &new); - para.touch(); + if groups.is_empty() { + continue; + } + // Each stretch keeps its own run's formatting (#421); right to left so offsets hold. + let para = s.doc.para_mut(a.story, &path)?; + for (start, end, new) in groups.iter().rev() { + if new.len() == end - start { + if para.text.get(*start..*end).is_some() { + para.text.replace_range(*start..*end, new); + } + } else { + let props = para.props_of_char(*start).clone(); + para.delete(*start, *end)?; + para.insert_text(*start, new, &props)?; + } + } + para.touch(); + // The selection keeps covering the converted text when its length changed (#420). + let spans: Vec<(usize, usize, usize)> = groups.iter().map(|(x, y, n)| (*x, *y, n.len())).collect(); + for end in [&mut s.sel.anchor, &mut s.sel.focus] { + if end.story == a.story && end.path == path { + end.off = map_offset(&spans, end.off); } } } sel_result(s) } +/// Group the characters of `src` (at `from` in `p`) whose converted form `conv` differs into +/// stretches that stay inside one run: `(start, end, replacement)`, in text order. +fn case_groups(p: &wordcraft_doc::Paragraph, from: usize, src: &str, conv: &[String]) -> Vec<(usize, usize, String)> { + let runs: Vec> = p.run_ranges().map(|(r, _)| r).collect(); + let run_of = |off: usize| runs.iter().position(|r| r.contains(&off)); + let mut out: Vec<(usize, usize, String, Option)> = Vec::new(); + for ((i, c), new) in src.char_indices().zip(conv) { + let (start, end) = (from + i, from + i + c.len_utf8()); + let mut buf = [0u8; 4]; + if new.as_str() == c.encode_utf8(&mut buf) { + continue; + } + let run = run_of(start); + match out.last_mut() { + Some(g) if g.1 == start && g.3 == run => { + g.1 = end; + g.2.push_str(new); + } + _ => out.push((start, end, new.clone(), run)), + } + } + out.into_iter().map(|(a, b, n, _)| (a, b, n)).collect() +} + +/// Where `off` lands after the stretches `(start, end, new_len)` (text order) were replaced; +/// an offset inside a stretch moves to its end. +fn map_offset(spans: &[(usize, usize, usize)], off: usize) -> usize { + let mut out = off; + for &(start, end, new_len) in spans { + if end <= off { + out = out.saturating_sub(end.saturating_sub(start)).saturating_add(new_len); + } else if start < off { + out = out.saturating_sub(off - start).saturating_add(new_len); + } + } + out +} + fn is_title(t: &str) -> bool { t.split_whitespace().all(|w| w.chars().next().is_some_and(char::is_uppercase)) } +#[cfg(test)] fn convert(src: &str, mode: &str, para_start: bool) -> String { - match mode { - "upper" => src.to_uppercase(), - "lower" => src.to_lowercase(), - "toggle" => src.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::() } else { c.to_uppercase().collect() }).collect(), - "title" => { - let mut out = String::with_capacity(src.len()); - let mut up = true; - for c in src.chars() { - if up && c.is_alphabetic() { - out.extend(c.to_uppercase()); - up = false; + convert_chars(src, mode, para_start).concat() +} + +/// The case-converted form of each character of `src`, in order (one entry per char, so a +/// change of length such as ß → SS or İ → i̇ can be applied run by run). +fn convert_chars(src: &str, mode: &str, para_start: bool) -> Vec { + let chars: Vec = src.chars().collect(); + // Greek capital sigma lowers to the final form at the end of a word (as `str::to_lowercase`). + let lower = |i: usize, c: char| -> String { + let prev = i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_alphabetic()); + let next = chars.get(i + 1).is_some_and(|n| n.is_alphabetic()); + if c == 'Σ' && prev && !next { "ς".to_string() } else { c.to_lowercase().collect() } + }; + // Title case capitalises the first word of any selection; sentence case only at a paragraph start. + let mut up = mode == "title" || para_start; + let mut out = Vec::with_capacity(chars.len()); + for (i, &c) in chars.iter().enumerate() { + let new = match mode { + "upper" => c.to_uppercase().collect(), + "lower" => lower(i, c), + "toggle" => { + if c.is_uppercase() { + lower(i, c) } else { - out.extend(c.to_lowercase()); + c.to_uppercase().collect() } + } + "title" => { + let n = if up && c.is_alphabetic() { + up = false; + c.to_uppercase().collect() + } else { + lower(i, c) + }; if c.is_whitespace() { up = true; } + n } - out - } - _ => { - // Sentence case. - let mut out = String::with_capacity(src.len()); - let mut up = para_start; - for c in src.chars() { - if up && c.is_alphabetic() { - out.extend(c.to_uppercase()); + _ => { + // Sentence case. + let n = if up && c.is_alphabetic() { up = false; + c.to_uppercase().collect() } else { - out.extend(c.to_lowercase()); - } + lower(i, c) + }; if matches!(c, '.' | '!' | '?') { up = true; } + n } - out - } + }; + out.push(new); } + out } fn clear(s: &mut Session, _: &Value) -> CmdResult { @@ -520,6 +587,8 @@ mod tests { assert_eq!(convert("hello WORLD", "title", true), "Hello World"); assert_eq!(convert("Hello", "toggle", true), "hELLO"); assert_eq!(convert("straße", "upper", true), "STRASSE"); + assert_eq!(convert("hello world", "title", false), "Hello World"); + assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος"); } } diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 93259d848..9970e3608 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -212,7 +212,17 @@ pub fn specs() -> Vec { /// Resolved paragraph props at the caret. fn cur(s: &Session) -> wordcraft_doc::resolve::ResolvedPara { - let props = s.doc.para_at(&s.sel.focus).map(|p| p.props.clone()).unwrap_or_default(); + // A selection ending at the next paragraph's start doesn't format that paragraph (#418), so + // its state comes from the last paragraph it does cover. + let (a, b) = s.sel.ordered(); + let mut at = s.sel.focus.clone(); + if let Some(last) = s.doc.para_paths_in(&a, &b).pop() + && last != at.path + && at == b + { + at = Pos { path: last, off: 0, ..at }; + } + let props = s.doc.para_at(&at).map(|p| p.props.clone()).unwrap_or_default(); s.doc.styles.resolve_para(&props) } @@ -299,7 +309,7 @@ fn asian_typography(s: &mut Session, v: &Value) -> CmdResult { pub fn indent(s: &mut Session, _: &Value) -> CmdResult { let (a, b) = s.sel.ordered(); - let paths = s.doc.paths_between(&a, &b); + let paths = s.doc.para_paths_in(&a, &b); let tab = s.doc.settings.default_tab.max(1.0); for path in paths { let (num, left) = { @@ -318,7 +328,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult { pub fn outdent(s: &mut Session, _: &Value) -> CmdResult { let (a, b) = s.sel.ordered(); - let paths = s.doc.paths_between(&a, &b); + let paths = s.doc.para_paths_in(&a, &b); let tab = s.doc.settings.default_tab.max(1.0); for path in paths { let (num, left) = { @@ -415,7 +425,7 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult { let (a, b) = s.sel.ordered(); // Toggle: if every selected paragraph already has this kind of list, remove it. let current: Vec> = - s.doc.paths_between(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect(); + s.doc.para_paths_in(&a, &b).iter().map(|p| s.doc.para(a.story, p).and_then(|x| x.props.numbering).filter(|n| n.num != 0)).collect(); let same_kind = |n: &NumRef| { s.doc.numbering.level(n.num, 0).is_some_and(|l| { let want = wordcraft_doc::numbering::levels_for(kind); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 3c2c743a7..cb29227ee 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -159,6 +159,8 @@ 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, } /// What [`Session::restore`] needs to take commands back exactly: the document, the selection, @@ -492,7 +494,7 @@ impl Session { return; } self.typing_open = label == "Typing"; - self.history.push(Arc::new(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone() })); + self.history.push(Arc::new(Undo { label: label.to_string(), doc: self.doc.clone(), sel: self.sel.clone(), pending: self.pending.clone() })); if self.history.len() > MAX_UNDO { self.history.remove(0); self.undo_evicted += 1; @@ -502,7 +504,7 @@ impl Session { /// 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 })); + self.history.push(Arc::new(Undo { label: label.to_string(), doc, sel, pending: self.pending.clone() })); if self.history.len() > MAX_UNDO { self.history.remove(0); self.undo_evicted += 1; @@ -553,7 +555,12 @@ impl Session { pub fn undo(&mut self) -> bool { self.typing_open = false; let Some(u) = self.history.pop().map(Arc::unwrap_or_clone) else { return false }; - let cur = Undo { label: u.label.clone(), doc: std::mem::replace(&mut self.doc, u.doc), sel: std::mem::replace(&mut self.sel, u.sel) }; + 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), + }; self.redo.push(Arc::new(cur)); self.touch(); true @@ -566,7 +573,12 @@ impl Session { 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) }; + 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), + }; self.history.push(Arc::new(cur)); self.touch(); true @@ -835,6 +847,11 @@ impl Session { } } self.clamp_selection(); + // 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 && !matches!(id, "edit.undo" | "edit.redo") { self.note_edit(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index bd852eefa..76ead0d20 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2947,6 +2947,71 @@ fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() { assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty()))); } +#[test] +fn center_after_select_paragraph_leaves_the_next_paragraph_alone() { + // #418: Select Paragraph ends at the next paragraph's start; that paragraph isn't selected. + use wordcraft_doc::Align; + let align = |s: &Session, i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.align); + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 2}})); + run(&mut s, "select.paragraph", json!({})); + assert_eq!(s.selected_text(), "Alpha\n"); + run(&mut s, "para.alignCenter", json!({})); + assert_eq!((align(&s, 0), align(&s, 1)), (Some(Align::Center), None)); + // Reversed, too; a range into Beta centres both; a caret at Beta's start centres Beta. + for (a, b, want) in [((1, 0), (0, 0), (Some(Align::Center), None)), ((0, 0), (1, 1), (Some(Align::Center), Some(Align::Center)))] { + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true})); + run(&mut s, "caret.set", json!({"pos": {"block": a.0, "off": a.1}})); + run(&mut s, "caret.set", json!({"pos": {"block": b.0, "off": b.1}, "extend": true})); + run(&mut s, "para.align", json!({"value": "center"})); + assert_eq!((align(&s, 0), align(&s, 1)), want, "{a:?}..{b:?}"); + } + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Alpha\rBeta", "raw": true})); + run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}})); + run(&mut s, "para.alignCenter", json!({})); + assert_eq!((align(&s, 0), align(&s, 1)), (None, Some(Align::Center))); +} + +#[test] +fn change_case_keeps_the_selection_when_lengths_change() { + // #420: İ lowers to i + U+0307 (one byte more); the selection still covers the result. + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "aİxZ", "raw": true})); + run(&mut s, "select.text", json!({"text": "İx"})); + run(&mut s, "format.changeCase", json!({"mode": "lower"})); + assert_eq!(text(&s), "ai\u{307}xZ"); + assert_eq!(s.selected_text(), "i\u{307}x"); + // ı uppers to I (one byte less); the trailing Z stays out of the selection. + let mut s = crate::Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "ıxZ", "raw": true})); + run(&mut s, "select.text", json!({"text": "ıx"})); + run(&mut s, "format.changeCase", json!({"mode": "upper"})); + assert_eq!(text(&s), "IXZ"); + assert_eq!(s.selected_text(), "IX"); +} + +#[test] +fn change_case_keeps_each_runs_formatting_when_lengths_change() { + // #421: lowercasing a plain İ and a bold x keeps x bold (and the i̇ plain). + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "İx", "raw": true})); + run(&mut s, "select.text", json!({"text": "x"})); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "select.all", json!({})); + run(&mut s, "format.changeCase", json!({"mode": "lower"})); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "i\u{307}x"); + assert_ne!(p.props_of_char(0).bold, Some(true)); + assert_ne!(p.props_of_char(1).bold, Some(true)); + assert_eq!(p.props_of_char(3).bold, Some(true)); + // Typing over the converted selection replaces all of it. + run(&mut s, "text.insert", json!({"text": "Q", "raw": true})); + assert_eq!(text(&s), "Q"); +} + #[test] fn document_grid_sets_lines_and_characters_in_one_undo_step() { // #391: Layout › Page Setup › Document Grid. diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index aafc2e2dc..19470d01b 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -395,6 +395,47 @@ fn typing_at_either_end_of_a_link_is_not_linked() { assert_eq!(links(&s), [("sixte".into(), false), (String::new(), false)]); } +#[test] +fn typing_then_selecting_then_typing_are_two_undo_steps() { + // #419: a selection that moves the insertion point ends the typing group, like a caret move. + let mut s = typed("alpha"); + run(&mut s, "select.text", json!({"text": "ph"})); + run(&mut s, "text.insert", json!({"text": "X"})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alXa"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha"); + assert_eq!(s.selected_text(), "ph"); + assert!(s.can_undo()); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); + // Uninterrupted typing (a read-only command in between included) is still one step. + let mut s = typed("alpha"); + run(&mut s, "document.inspect", json!({})); + typ(&mut s, "X"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, ""); +} + +#[test] +fn undoing_bold_at_the_caret_turns_bold_typing_off_again() { + // #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold. + let mut s = s(); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "edit.undo", json!({})); + assert_ne!(s.typing_props().bold, Some(true)); + typ(&mut s, "abc"); + let p = s.doc.para_at(&Pos::body(0, 0)).unwrap(); + assert_eq!(p.text, "abc"); + assert_ne!(p.props_of_char(0).bold, Some(true)); + // Redo brings bold typing back. + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "format.bold", json!({"value": true})); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.redo", json!({})); + typ(&mut s, "abc"); + assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props_of_char(0).bold, Some(true)); +} + /// Press a shortcut the way the keyboard does: run the command bound to it. fn press(s: &mut Session, combo: &str) -> Result { let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}")); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 6d3360911..a62eebc7b 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (names of days capitalised; AutoCorrect Options switch each AutoCorrect and AutoFormat behaviour, #412; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (undo grouping after a selection, Undo of Bold at the caret, #419, #424; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; text typed next to a hyperlink stays outside it, #413) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -24,6 +24,13 @@ overall picture is in [`target-app-parity.md`](target-app-parity.md). | `1. ` after other text | a new list restarting at 1 (never continues an earlier list) | `typing_1_dot_starts_numbering_again` | | ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` | +## Undo + +| Keys | Word's result | Test | +|---|---|---| +| type, select some of it, type over it | two undo steps: the first ⌘Z brings the selected text back (still selected), the second removes the typing. Uninterrupted typing stays one step | `typing_then_selecting_then_typing_are_two_undo_steps` | +| ⌘B with nothing selected, ⌘Z, type | the text isn't bold (Undo takes the waiting caret formatting back too); ⌘Y brings it back | `undoing_bold_at_the_caret_turns_bold_typing_off_again` | + ## Track Changes | Keys | Word's result | Test | @@ -97,6 +104,7 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Undo rows: a selection ends the typing group (#419); Undo of Bold at the caret restores unbolded typing (#424) | | 2026-10-11 | minor | Names of days capitalised; AutoCorrect Options (user entries, deleted built-ins, exceptions, every AutoCorrect/AutoFormat switch) change typing (#412) | | 2026-10-11 | minor | Editing keys: Move Paragraph Up/Down, Promote/Demote, Go Back (Shift+F5), Alt+X (#343) | | 2026-10-11 | minor | Hyperlinks: text typed at either end of a link or after Enter stays outside it (#413) |