Compare commits

...
Author SHA1 Message Date
Martin BallandClaude Sonnet 5.5 4ebfc3c2a7 #3518: whole-document OK sends only what was changed in the Language dialog
With "Apply to the whole document" ticked, OK sends noProof only if the user
changed it from the dialog's opening value, and lang if changed or if neither
changed (never the engine's en-US default). Heading reads "Mark the whole
document as:" while ticked. Glossary walker uses doc's PARA_WALK_DEPTH (now pub).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 16:09:02 +00:00
Martin BallandClaude Sonnet 5.5 c09da43475 #3518: Review › Language can set the whole document's proofing language
review.language takes scope "selection" (default, unchanged) or "document":
every run and paragraph mark of every story (body, headers, footers, notes,
comments, text boxes, table cells, glossary) takes lang/noProof in one undo
step, selection unchanged, pending typing format follows. The Language dialog
gets an "Apply to the whole document" check box.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCVCCEo7u5wetHaJRU4
2026-11-06 15:30:26 +00:00
9 changed files with 297 additions and 14 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -90,8 +90,8 @@ pub use settings::{
COMPAT_MODE_CURRENT, CoreProps, CustomProp, DEFAULT_GRID, DEFAULT_HYPHENATION_ZONE, LEGACY_COMPAT_MODE, OdfLayout, PagePicture, Settings, Source,
THEME_COLORS, Watermark, Zoom, ZoomFit,
};
pub(crate) use text_walk::{PARA_WALK_DEPTH, walk_between};
pub use text_walk::{count_chars, count_chars_no_spaces, count_words};
pub(crate) use text_walk::walk_between;
pub use text_walk::{PARA_WALK_DEPTH, count_chars, count_chars_no_spaces, count_words};
/// A document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -48,7 +48,7 @@ pub fn count_chars_no_spaces(s: &str) -> usize {
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
pub const PARA_WALK_DEPTH: usize = 16;
/// The note and text box stories an object shows, for `Document::reachable`.
pub(crate) fn notes_and_boxes(o: &InlineObject) -> Vec<(u32, &'static [PartKind])> {
+66 -3
View File
@@ -1,4 +1,5 @@
use super::{Arc, CmdError, CmdResult, Document, Pos, Selection, Session, StoryRef, Value, delete_selection, json, p, sel_result};
use wordcraft_doc::CharProps;
/// Options › Language › Set as default: only the saved default proofing language (what future
/// blank documents are written in), as Word's Set as Preferred. A setting, like `file.options`: the
@@ -14,8 +15,15 @@ pub(super) fn preferred_language(s: &mut Session, v: &Value) -> CmdResult {
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default and the saved language for future blank documents;
/// `detect` is the per-user detection preference.
/// Nothing at all sets US English, as before.
/// Nothing at all sets US English, as before. `scope: "document"` sets it for every story.
pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
// Checked first, so a bad scope changes nothing (not even the detect preference).
let whole_document = match v.get("scope") {
None | Some(Value::Null) => false,
Some(Value::String(t)) if t == "selection" => false,
Some(Value::String(t)) if t == "document" => true,
Some(other) => return Err(CmdError::Params(format!("`scope` must be \"selection\" or \"document\", not {other}"))),
};
let lang = match p::str(v, "lang") {
Some(t) => Some(
wordcraft_proof::lang::normalize_tag(t)
@@ -45,20 +53,75 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
let result = crate::cmd::format::apply(s, &|c| {
let change = |c: &mut CharProps| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})?;
};
let result = if whole_document {
set_document_props(s, &change)?;
json!({"scope": "document"})
} else {
crate::cmd::format::apply(s, &change)?
};
if p::bool(v, "default") == Some(true) {
s.prefs.default_language = lang;
}
Ok(result)
}
/// Apply `f` to the character formatting of every story of the document (Language dialog, "Apply
/// to the whole document"): text and paragraph marks of the body, headers, footers, notes,
/// comments, text boxes (all `Document::parts`), table cells, and the glossary's building blocks.
/// The selection is untouched; formatting waiting for typing follows.
fn set_document_props(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> Result<(), CmdError> {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
for story in stories {
for path in s.doc.para_paths(story) {
let para = s.doc.para_mut(story, &path)?;
let len = para.len();
para.format(0, len, f)?;
f(&mut para.mark);
para.touch();
}
}
for block in &mut s.doc.glossary {
format_blocks(&mut block.blocks, f, 0);
}
if let Some(pend) = s.pending.as_mut() {
f(pend);
}
Ok(())
}
/// [`set_document_props`] for a block list outside any story (depth-limited like `para_paths`).
fn format_blocks(blocks: &mut wordcraft_doc::Blocks, f: &dyn Fn(&mut CharProps), depth: usize) {
if depth > wordcraft_doc::PARA_WALK_DEPTH {
return;
}
for blk in blocks.iter_mut() {
match Arc::make_mut(blk) {
wordcraft_doc::Block::Para(p) => {
let len = p.len();
// A range inside the paragraph can't fail; the mark is formatted regardless.
let _ = p.format(0, len, f);
f(&mut p.mark);
p.touch();
}
wordcraft_doc::Block::Table(t) => {
for row in &mut t.rows {
for cell in &mut row.cells {
format_blocks(&mut cell.blocks, f, depth + 1);
}
}
}
}
}
}
pub(super) fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
+1 -1
View File
@@ -340,7 +340,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents), "scope"?: "selection" (default) | "document" (every run and paragraph mark of every story: body, headers, footers, notes, comments, text boxes, tables, glossary; one undo step, selection unchanged)}"#,
),
CommandSpec::new("review.language.preferred", "Set as Default Language", "File › Options › Language", preferred_language)
.params(r#"{"lang": "en-US|en-GB|pl-PL|nl-NL|…" (BCP 47)} — only the saved default proofing language, what future blank documents are written in, as Options › Language › Set as default does; the open document, the selection and the undo history don't change. Returns {"preferred": tag}."#)
+101
View File
@@ -290,3 +290,104 @@ fn inspect_removals_work_when_the_document_does_not_track_changes() {
assert!(!text.contains("Hidden"), "the hidden text is still there: {text}");
assert!(text.contains("Visible text."), "{text}");
}
/// #3518: every run and paragraph mark of a story, as (story, path, lang, noProof) rows.
fn proofing_rows(s: &Session) -> Vec<(StoryRef, Option<String>, Option<bool>)> {
let stories = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k)));
let mut out = Vec::new();
for st in stories {
for path in s.doc.para_paths(st) {
let Some(p) = s.doc.para(st, &path) else { continue };
out.push((st, p.mark.lang.clone(), p.mark.no_proof));
out.extend(p.runs.iter().map(|r| (st, r.props.lang.clone(), r.props.no_proof)));
}
}
out
}
/// A document with its text in German in the body, a table cell, a header, a footnote, a comment
/// and a text box, as a Word DOCX keeps a `w:lang` on each run, plus a glossary entry.
fn multi_story_doc() -> Session {
use wordcraft_doc::{Block, BuildingBlock, CharProps, Paragraph, PartKind, Table};
let de = || CharProps { lang: Some("de-DE".into()), ..Default::default() };
let blk = |t: &str| Arc::new(Block::Para(Paragraph::with_text(t, de())));
let mut d = Document::from_text("Guten Tag");
d.body = vec![blk("Guten Tag")];
let mut t = Table::new(1, 2, 200.0);
if let Some(c) = t.rows.first_mut().and_then(|r| r.cells.first_mut()) {
c.blocks = vec![blk("Zelle")];
}
d.body.push(Arc::new(Block::Table(t)));
d.body.push(blk("Ende"));
for kind in [PartKind::Header, PartKind::Footnote, PartKind::Comment, PartKind::TextBox] {
d.add_part(kind, vec![blk("Teil")]);
}
d.glossary.push(BuildingBlock { name: "g".into(), blocks: vec![blk("Baustein")], ..Default::default() });
Session::new(d)
}
#[test]
fn language_scope_document_sets_every_story() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
let (sel, before) = (s.sel.clone(), s.doc.clone());
let rows = proofing_rows(&s);
assert!(rows.len() >= 10 && rows.iter().any(|r| r.1.as_deref() == Some("de-DE")));
s.run("review.language", &json!({"lang": "fr-FR", "scope": "document"})).unwrap();
// Runs that had their own language, the paragraph marks and the empty cell: all French.
let rows = proofing_rows(&s);
assert!(rows.iter().all(|r| r.1.as_deref() == Some("fr-FR")), "{rows:?}");
for kind_story in s.doc.parts.keys() {
assert!(rows.iter().any(|r| r.0 == StoryRef::Part(*kind_story)));
}
assert!(s.doc.plain_text(StoryRef::Body).contains("Zelle"));
let g = &s.doc.glossary[0].blocks;
assert_eq!(g[0].as_para().map(|p| p.runs[0].props.lang.clone()), Some(Some("fr-FR".into())));
assert_eq!(g[0].as_para().map(|p| p.mark.lang.clone()), Some(Some("fr-FR".into())));
// The selection stays; the document is modified.
assert_eq!(s.sel, sel);
assert!(s.doc != before);
// One undo restores everything.
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc == before);
assert_eq!(s.sel, sel);
}
#[test]
fn language_scope_document_no_proof_and_pending() {
let mut s = multi_story_doc();
s.run("review.language", &json!({"noProof": true, "scope": "document"})).unwrap();
assert!(proofing_rows(&s).iter().all(|r| r.2 == Some(true)));
// The language itself wasn't asked for and stays.
assert!(proofing_rows(&s).iter().any(|r| r.1.as_deref() == Some("de-DE")));
// Formatting waiting for typing follows.
s.run("format.bold", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "pl-PL", "scope": "document"})).unwrap();
assert_eq!(s.pending.as_ref().and_then(|p| p.lang.as_deref()), Some("pl-PL"));
}
#[test]
fn language_default_scope_is_selection_only() {
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR"})).unwrap();
let rows = proofing_rows(&s);
assert!(rows.iter().any(|r| r.1.as_deref() == Some("fr-FR")));
assert!(rows.iter().filter(|r| r.1.as_deref() == Some("fr-FR")).count() < rows.len() / 2);
let mut s = multi_story_doc();
s.run("select.text", &json!({"text": "Guten"})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR", "scope": "selection"})).unwrap();
assert_eq!(proofing_rows(&s), rows);
}
#[test]
fn language_bad_scope_is_a_params_error() {
let mut s = multi_story_doc();
let before = s.doc.clone();
for bad in [json!("everything"), json!(1), json!(true), json!(["document"])] {
let e = s.run("review.language", &json!({"lang": "fr-FR", "detect": false, "scope": bad})).unwrap_err();
assert!(matches!(e, CmdError::Params(_)), "{e:?}");
}
assert!(s.doc == before);
assert!(s.prefs.detect_language);
}
@@ -208,7 +208,13 @@ fn language_dialog_default_survives_a_restart() {
use egui_kittest::kittest::Queryable;
let mut a = app();
a.ui.language = "en".into();
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(LanguageForm { lang: "fr-FR".into(), no_proof: false, detect: false }))));
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(LanguageForm {
lang: "fr-FR".into(),
no_proof: false,
detect: false,
whole_document: false,
initial: ("fr-FR".into(), false),
}))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(&ui.ctx().clone());
@@ -232,6 +238,96 @@ fn language_dialog_default_survives_a_restart() {
assert_eq!(LanguageForm::read(&b).lang, "fr-FR");
}
/// #3518: ticking "Apply to the whole document" makes OK send `scope: "document"`; unticked sends none.
#[test]
fn language_dialog_whole_document_checkbox_sends_scope() {
use egui_kittest::kittest::Queryable;
let f = LanguageForm { lang: "fr-FR".into(), no_proof: false, detect: false, whole_document: false, initial: ("fr-FR".into(), false) };
assert!(f.params().get("scope").is_none());
assert_eq!(LanguageForm { whole_document: true, ..f.clone() }.params()["scope"], "document");
let mut a = app();
a.ui.language = "en".into();
let para = wordcraft_doc::Paragraph::with_text("Header", Default::default());
let header = a.session.doc.add_part(wordcraft_doc::PartKind::Header, vec![Arc::new(wordcraft_doc::Block::Para(para))]);
a.dialog = Some(Dialog::Layout(Box::new(LayoutDialog::Language(f))));
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
a,
);
h.run_steps(3);
h.get_by_label("Apply to the whole document").click();
h.run_steps(3);
h.get_by_label("OK").click();
h.run_steps(3);
// The header, which the caret isn't in, took the language.
let doc = &h.state().session.doc;
let hp = doc.para(wordcraft_doc::StoryRef::Part(header), &wordcraft_doc::Path::top(0)).unwrap();
assert_eq!(hp.runs.first().and_then(|r| r.props.lang.as_deref()), Some("fr-FR"));
}
/// "Plain text, then a Do-not-check run", the caret in the plain text.
fn proofing_mix_app() -> WordApp {
let mut a = app();
a.run("text.insert", json!({"text": "Plain code"})).unwrap();
a.run("select.text", json!({"text": "code"})).unwrap();
a.run("review.language", json!({"lang": "de-DE", "noProof": true})).unwrap();
a.run("select.text", json!({"text": "Plain"})).unwrap();
a.run("review.language", json!({"lang": "en-US", "noProof": false})).unwrap();
a.run("select.text", json!({"text": "Plain"})).unwrap();
a
}
fn run_proofing(a: &mut WordApp, text: &str) -> (Option<String>, Option<bool>) {
a.run("select.text", json!({"text": text})).unwrap();
let st = a.run("format.state", json!({})).unwrap();
(st["lang"].as_str().map(String::from), st["noProof"].as_bool())
}
/// #3518: whole-document OK sends only what the user changed.
#[test]
fn language_dialog_whole_document_sends_only_changes() {
// Changing only the language leaves the Do-not-check run alone.
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.whole_document = true;
f.lang = "en-GB".into();
let p = f.params();
assert!(p.get("noProof").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("en-GB".into()), Some(true)));
assert_eq!(run_proofing(&mut a, "Plain"), (Some("en-GB".into()), Some(false)));
// Changing only Do-not-check leaves each run's language alone.
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.whole_document = true;
f.no_proof = true;
let p = f.params();
assert!(p.get("lang").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("de-DE".into()), Some(true)));
assert_eq!(run_proofing(&mut a, "Plain"), (Some("en-US".into()), Some(true)));
// No edits: the caret's language, no noProof (never the engine's en-US default).
let mut a = proofing_mix_app();
let mut f = LanguageForm::read(&a);
f.lang = "fr-FR".into();
f.initial.0 = "fr-FR".into();
f.whole_document = true;
let p = f.params();
assert_eq!(p["lang"], "fr-FR");
assert!(p.get("noProof").is_none());
a.run("review.language", p).unwrap();
assert_eq!(run_proofing(&mut a, "code"), (Some("fr-FR".into()), Some(true)));
// Selection mode is unchanged: both are sent.
f.whole_document = false;
assert_eq!(f.params()["noProof"], false);
}
/// #407: Envelopes starts from the selected address; Add to Document puts an envelope
/// section before the letter without asking to save (nothing is replaced).
#[test]
@@ -320,6 +320,11 @@ pub struct LanguageForm {
pub lang: String,
pub no_proof: bool,
pub detect: bool,
/// "Apply to the whole document": OK sends `scope: "document"` (every story), not the selection.
pub whole_document: bool,
/// The language and Do-not-check state the dialog opened with, to tell what the user changed.
#[serde(skip)]
pub initial: (String, bool),
}
impl LanguageForm {
@@ -328,12 +333,27 @@ impl LanguageForm {
let style = s.doc.para_at(&s.sel.focus).and_then(|p| p.props.style.as_deref());
let here = s.doc.styles.resolve_char(style, &s.typing_props());
let lang = here.lang.unwrap_or_else(|| "en-US".into());
LanguageForm { lang, no_proof: here.no_proof, detect: s.prefs.detect_language }
LanguageForm { initial: (lang.clone(), here.no_proof), lang, no_proof: here.no_proof, detect: s.prefs.detect_language, whole_document: false }
}
/// `review.language` parameters for OK.
pub fn params(&self) -> Value {
json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect})
if !self.whole_document {
return json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect});
}
// Whole document: the caret's values must not flatten what differs elsewhere, so send only
// what was changed. With no edits at all the language goes (never the engine's en-US default).
let (lang_changed, proof_changed) = (self.lang != self.initial.0, self.no_proof != self.initial.1);
let mut v = json!({"scope": "document", "detect": self.detect});
if let Some(m) = v.as_object_mut() {
if lang_changed || !proof_changed {
m.insert("lang".into(), json!(self.lang));
}
if proof_changed {
m.insert("noProof".into(), json!(self.no_proof));
}
}
v
}
}
@@ -343,7 +363,7 @@ pub(super) fn language_name(tag: &str) -> String {
}
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
ui.label(tl!("Mark selected text as:"));
ui.label(if f.whole_document { tl!("Mark the whole document as:") } else { tl!("Mark selected text as:") });
let list = crate::ribbon::language_list(&f.lang);
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
@@ -360,6 +380,7 @@ pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm)
ui.add_space(4.0);
ui.checkbox(&mut f.no_proof, tl!("Do not check spelling or grammar"));
ui.checkbox(&mut f.detect, tl!("Detect language automatically"));
ui.checkbox(&mut f.whole_document, tl!("Apply to the whole document"));
ui.add_space(4.0);
let set_default = ui.button(tl!("Set As Default")).clicked();
if set_default && run(app, "review.language", json!({"lang": f.lang, "default": true, "detect": f.detect})) {
+3 -2
View File
File diff suppressed because one or more lines are too long