mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 11:50:20 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ebfc3c2a7 | ||
|
|
c09da43475 | ||
|
|
729cd0a5fc |
+4
-1
File diff suppressed because one or more lines are too long
@@ -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)]
|
||||
|
||||
@@ -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])> {
|
||||
|
||||
@@ -652,19 +652,33 @@ pub(crate) fn typing_pos(s: &Session, p: &Pos) -> Pos {
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let Some(before) = para.text.get(..p.off) else { return p.clone() };
|
||||
let Some(after) = para.text.get(p.off..) else { return p.clone() };
|
||||
if !before.ends_with(OBJ) && !after.starts_with(OBJ) {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: HashSet<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
// Count the preceding objects once. Each adjacent marker can then use the matching
|
||||
// object directly, forwards for starts and backwards for ends, even with short storage.
|
||||
let index = before.chars().filter(|c| *c == OBJ).count();
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
if is_start(off) {
|
||||
while is_start(off) {
|
||||
off += w;
|
||||
for (ch, object) in after.chars().zip(para.objects.iter().skip(index)) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermStart { id, .. } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off += w;
|
||||
}
|
||||
if off > p.off {
|
||||
return Pos { off, ..p.clone() };
|
||||
}
|
||||
while off >= w && is_end(off - w) {
|
||||
off -= w;
|
||||
if let Some(objects) = para.objects.get(..index) {
|
||||
for (ch, object) in before.chars().rev().zip(objects.iter().rev()) {
|
||||
if ch != OBJ || !matches!(object, InlineObject::PermEnd { id } if editable.contains(id)) {
|
||||
break;
|
||||
}
|
||||
off -= w;
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
}
|
||||
|
||||
@@ -922,3 +922,159 @@ fn legacy_password_is_verified_and_unknown_forms_refused() {
|
||||
assert!(e.to_string().contains(super::UNSUPPORTED_PASSWORD));
|
||||
assert!(super::enforced(&s.doc));
|
||||
}
|
||||
|
||||
/// Consecutive permission boundaries must not recount the paragraph prefix or search all
|
||||
/// editable IDs once per marker. Both ends are used by typing and Enter.
|
||||
#[test]
|
||||
fn typing_boundary_many_permission_markers_is_not_quadratic() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let cpu = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::<u64>().ok();
|
||||
let measure = |n: usize, at_end: bool| {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text.push_str("λ prefix ");
|
||||
let start = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermStart { id: i as u32, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
p.text.push_str("body");
|
||||
let end = p.text.len();
|
||||
for i in 0..n {
|
||||
p.text.push(OBJ);
|
||||
p.objects.push(InlineObject::PermEnd { id: i as u32 });
|
||||
}
|
||||
let after = p.text.len();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = Some("readOnly".into());
|
||||
let s = Session::new(doc);
|
||||
let at = Pos::body(0, if at_end { after } else { start });
|
||||
let expected = Pos::body(0, if at_end { end } else { start + n * OBJ.len_utf8() });
|
||||
let before = cpu();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(typing_pos(std::hint::black_box(&s), &at), expected);
|
||||
}
|
||||
match (before, cpu()) {
|
||||
(Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for at_end in [false, true] {
|
||||
let (small, large) = (measure(2_000, at_end), measure(16_000, at_end));
|
||||
let (Some(a), Some(b)) = (small, large) else {
|
||||
eprintln!("Typing boundary: CPU counter unavailable; semantic checks passed, timing assertion skipped");
|
||||
continue;
|
||||
};
|
||||
eprintln!("Typing boundary end={at_end}: small={a:.9}s large={b:.9}s ratio={:.2}", b / a);
|
||||
assert!(b < a * 24.0 + 0.005, "8x permission markers must not take quadratic boundary work: {a:.9}s -> {b:.9}s");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_author_precedence_and_malformed_pairing() {
|
||||
use wordcraft_doc::para::Run;
|
||||
// Frozen boundary semantics from before the one-pass lookup. This oracle deliberately
|
||||
// uses object_at independently of the new traversal, including malformed storage.
|
||||
let original = |s: &Session, p: &Pos| {
|
||||
if !matches!(s.doc.settings.protection.as_deref(), Some("readOnly" | "comments")) {
|
||||
return p.clone();
|
||||
}
|
||||
let Some(para) = s.doc.para_at(p) else { return p.clone() };
|
||||
if para.objects.is_empty() {
|
||||
return p.clone();
|
||||
}
|
||||
let editable: Vec<u32> = s.doc.perm_ranges().iter().filter(|r| editable_by(r, &s.author)).map(|r| r.id).collect();
|
||||
let start = |off| matches!(para.object_at(off), Some(InlineObject::PermStart { id, .. }) if editable.contains(id));
|
||||
let end = |off| matches!(para.object_at(off), Some(InlineObject::PermEnd { id }) if editable.contains(id));
|
||||
let mut off = p.off;
|
||||
if start(off) {
|
||||
while start(off) {
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
} else {
|
||||
while off >= OBJ.len_utf8() && end(off - OBJ.len_utf8()) {
|
||||
off -= OBJ.len_utf8();
|
||||
}
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
};
|
||||
let objects = [
|
||||
InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() },
|
||||
InlineObject::PermStart { id: 2, group: String::new(), editor: " ALICE ".into() },
|
||||
InlineObject::PermStart { id: 3, group: String::new(), editor: "alice".into() },
|
||||
InlineObject::PermEnd { id: 1 },
|
||||
InlineObject::PermEnd { id: 3 },
|
||||
InlineObject::BookmarkStart { name: "Original".into() },
|
||||
InlineObject::PermEnd { id: 7 },
|
||||
InlineObject::PermStart { id: 7, group: String::new(), editor: "Bob".into() },
|
||||
];
|
||||
for text in
|
||||
[format!("λ{}x{}終", OBJ.to_string().repeat(4), OBJ.to_string().repeat(4)), OBJ.to_string().repeat(9), format!("λ{OBJ}{OBJ}x{OBJ}{OBJ}{OBJ}")]
|
||||
{
|
||||
for count in 0..=objects.len() {
|
||||
for mode in [None, Some("readOnly"), Some("comments"), Some("forms"), Some("trackedChanges")] {
|
||||
let mut p = wordcraft_doc::Paragraph::new();
|
||||
p.text = text.clone();
|
||||
p.objects = objects[..count].to_vec();
|
||||
p.runs.push(Run { len: p.text.len(), props: CharProps::default() });
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![Block::Para(p).into()];
|
||||
doc.settings.protection = mode.map(str::to_string);
|
||||
let mut s = Session::new(doc);
|
||||
s.author = " Alice ".into();
|
||||
for off in 0..=text.len() + 1 {
|
||||
let at = Pos::body(0, off);
|
||||
assert_eq!(typing_pos(&s, &at), original(&s, &at), "mode={mode:?} count={count} off={off} text={text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(Document::from_text("outside"));
|
||||
s.doc.settings.protection = Some("readOnly".into());
|
||||
assert_eq!(typing_pos(&s, &Pos::body(99, 0)), Pos::body(99, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_boundary_keeps_duplicate_ids_across_stories_and_start_precedence() {
|
||||
use wordcraft_doc::para::Run;
|
||||
let paragraph = |text: String, objects| wordcraft_doc::Paragraph {
|
||||
runs: vec![Run { len: text.len(), props: CharProps::default() }],
|
||||
text,
|
||||
objects,
|
||||
..Default::default()
|
||||
};
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}x{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: String::new(), editor: "Bob".into() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
doc.add_part(
|
||||
wordcraft_doc::PartKind::Header,
|
||||
vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}y{OBJ}"),
|
||||
vec![InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }, InlineObject::PermEnd { id: 1 }],
|
||||
))
|
||||
.into(),
|
||||
],
|
||||
);
|
||||
doc.settings.protection = Some("comments".into());
|
||||
let mut s = Session::new(doc);
|
||||
s.author = "Alice".into();
|
||||
// Preserve the existing document-wide ID membership, even for a duplicate in another story.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 0)), Pos::body(0, OBJ.len_utf8()));
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 7)), Pos::body(0, 4));
|
||||
s.doc.body = vec![
|
||||
Block::Para(paragraph(
|
||||
format!("{OBJ}{OBJ}x"),
|
||||
vec![InlineObject::PermEnd { id: 1 }, InlineObject::PermStart { id: 1, group: "everyone".into(), editor: String::new() }],
|
||||
))
|
||||
.into(),
|
||||
];
|
||||
// A start to the right wins over an end to the left.
|
||||
assert_eq!(typing_pos(&s, &Pos::body(0, 3)), Pos::body(0, 6));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}."#)
|
||||
|
||||
@@ -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})) {
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
+5
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user