#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
This commit is contained in:
Martin Ball
2026-11-06 15:30:26 +00:00
co-authored by Claude Sonnet 5.5
parent 729cd0a5fc
commit c09da43475
7 changed files with 218 additions and 10 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+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 > 32 {
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,12 @@ 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,
}))));
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 +237,36 @@ 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 };
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"));
}
/// #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,8 @@ 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,
}
impl LanguageForm {
@@ -328,12 +330,16 @@ 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 { 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})
let mut v = json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect});
if let (true, Some(m)) = (self.whole_document, v.as_object_mut()) {
m.insert("scope".into(), json!("document"));
}
v
}
}
@@ -360,6 +366,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