Editing fixes: Center range, undo grouping, Change Case runs and offsets, Bold undo at caret (#418, #419, #420, #421, #424) (#477)

- Paragraph formatting skips the paragraph a non-empty selection ends at the start of
  (Select Paragraph's end), via Document::para_paths_in (#418).
- A non-mutating command that moves the selection closes the typing group (#419).
- Change Case converts char by char and replaces only changed stretches inside one run,
  keeping each run's formatting (#421) and mapping the selection to the new offsets (#420).
- Undo/redo steps carry the pending caret formatting (#424).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 17:13:50 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 4b5ed293d7
commit 50505d9e62
7 changed files with 274 additions and 52 deletions
+14 -2
View File
@@ -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<Path> {
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();
+110 -41
View File
@@ -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<std::ops::Range<usize>> = 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<usize>)> = 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::<String>() } 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<String> {
let chars: Vec<char> = 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), "σοφος οδος");
}
}
+14 -4
View File
@@ -212,7 +212,17 @@ pub fn specs() -> Vec<CommandSpec> {
/// 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<Option<NumRef>> =
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);
+21 -4
View File
@@ -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<CharProps>,
}
/// 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();
}
+65
View File
@@ -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.
+41
View File
@@ -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<serde_json::Value, crate::CmdError> {
let id = s.registry.by_shortcut(combo).map(|c| c.id).unwrap_or_else(|| panic!("nothing bound to {combo}"));
+9 -1
View File
@@ -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) |