mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
4b5ed293d7
commit
50505d9e62
+14
-2
@@ -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
@@ -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), "σοφος οδος");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"));
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user