Compare commits

..
Author SHA1 Message Date
Martin BallandClaude Opus 5.5 c3e893a80b CP4.3: review — ROADMAP has one status header again after the merge from main (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 21:19:36 +00:00
Martin Ball 1c25cd7f43 Merge remote-tracking branch 'origin/main' into fix/language-proofing
; Conflicts:
;	ROADMAP.md
;	docs/ui-parity.md
2026-11-03 21:13:09 +00:00
Martin BallandClaude Opus 5.5 d56a3c9ba6 CP4.3: review — a language closed before the list was known closes the pack the whole list has for it (de-CH: Swiss German, not German) (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 19:15:47 +00:00
Martin BallandClaude Opus 5.5 53104c27b9 CP4.3: review — closing a regional pack's bar (de-ch) no longer closes its base language's (de); docs name Add a language… (CodeRabbit)
Tags closed before the list was known are kept apart from closed packs: only a tag maps to the pack it would find.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 19:03:30 +00:00
Martin BallandClaude Opus 5.5 78c8a6f600 CP4.3: review — gap 8: without a pack, other languages hyphenate with the English patterns (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 18:31:04 +00:00
Martin BallandClaude Opus 5.5 7aaa2ac4d2 CP4.3: review — Your languages picks the most specific pack, installed or offered, as the bar does: with ca installed, Valencian text still offers ca-valencia (CodeRabbit)
Docs: the missing-pack bar is provisional before the list of packs is known; Belarusian's old label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:45:34 +00:00
Martin BallandClaude Opus 5.5 91a93ff375 CP4.3: review — ROADMAP has one status header; progress notes use the Your languages status names (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:37:11 +00:00
Martin BallandClaude Opus 5.5 a2b1a0d407 CP4.3: review — proof.packs describes its languages rows; docs name every proofing status and scope #8 to built-in proofing (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:28:19 +00:00
Martin BallandClaude Opus 5.5 b513531d46 CP4.3: review — Add a language mirrors right to left; status lines keep main's history; the Polish row no longer claims per-language found lines
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 17:05:28 +00:00
Martin BallandClaude Opus 5.5 9b426294c3 CP4.3: review — docs list every proofing status, Set as default is for new blank documents, proof.packs schema renders (CodeRabbit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:42:29 +00:00
Martin BallandClaude Opus 5.5 f117720455 CP4.3: review — Valencian text is on the ca-valencia pack's row in Options › Language, as the bar offers it (CodeRabbit)
locale_for_tag now tries pack_for_tag's candidates in the same order, so the tag without its region (ca-valencia) comes before the region alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:38:07 +00:00
Martin BallandClaude Opus 5.5 9df47ffa77 CP4.3: the missing-pack bar works on a fresh install, counts the caret's language, and the spelling menu heading is in the interface language
With no list of packs cached (a fresh install, or one cached for an older pin) the bar still offers a document language with no dictionary; Install fetches the list, then the pack. With downloading on, a missing or stale list is fetched once by itself. A language closed before the list stays closed after it; a refused get isn't queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:36:41 +00:00
Martin BallandClaude Sonnet 5.5 6aabc503aa CP4.3: Options › Language review: rows follow the editing language, honest Set as default hover, narrow windows, proof.packs languages documented
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:30:39 +00:00
Martin BallandClaude Sonnet 5.5 c87591f1ab CP4.3: Options › Language: Set as default sets only the saved default proofing language (review.language preferredOnly), not the open document or selection
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:30:28 +00:00
Martin BallandClaude Sonnet 5.5 7efebfec08 CP4.3: Options › Language: Your languages with proofing status, Add a language… picker, Set as default; dictionaries folder moves from Proofing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:30:28 +00:00
Martin BallandClaude Opus 5.5 bd58e3cde2 CP4.3: review — Valencian is ca-ES-valencia and finds its pack; a bare pack-language tag (nl) is the nl-NL row, in the menu and the dialog
pack_for_tag tries a variant without its region before the region alone
(ca-ES-valencia -> ca-valencia -> ca-es -> ca), as craft-proof now does.
MORE_LANGUAGES stores Word's ca-ES-valencia; tag_for_pack and language_name
map ca-valencia onto it. language_checks and language_list compare through
same_language: the same tag, or named alike and proofed alike (two pack
languages read from the same pack, or the same locale without its region
when no pack is known), so "nl" no longer shows two Dutch rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:29:45 +00:00
Martin BallandClaude Opus 5.5 00c025b950 CP4.3: one language list: Language pack languages in Set Proofing Language, Review › Language and the status bar; every published pack named in English
Set Proofing Language lists the built-in languages and every installed or
available pack's language (Bulgarian, Belarusian, Spanish (Cuba)...), each
once and sorted by name; Review › Language and the status bar's menu show
the short list plus installed pack languages, checked one way (tag, or the
same ProofLang named alike) instead of hand-written cases. A pack's language
is stored as a tag that finds the pack again: pack_for_tag now tries every
prefix, so sr-Latn-RS is proofed by sr-latn, not the Cyrillic sr.
MORE_LANGUAGES names every locale of language-packs-2 (Belarusian showed as
Беларуская).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
2026-11-03 16:29:45 +00:00
36 changed files with 1151 additions and 1324 deletions
Generated
+6 -35
View File
@@ -1103,7 +1103,7 @@ dependencies = [
[[package]]
name = "craft-proof"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=01315d9c92b5b0e69082d35ca56099461a1efd45#01315d9c92b5b0e69082d35ca56099461a1efd45"
source = "git+https://github.com/storytold/craft-libs?rev=47c9f62530db72bc5c3831c86653b47b20c640be#47c9f62530db72bc5c3831c86653b47b20c640be"
dependencies = [
"log",
"miniz_oxide 0.9.1",
@@ -1520,19 +1520,6 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1600,26 +1587,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -7050,7 +7017,6 @@ dependencies = [
"craft-update",
"craft-window",
"egui",
"egui_extras",
"egui_kittest",
"image",
"js-sys",
@@ -7614,6 +7580,11 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+3 -4
View File
@@ -24,11 +24,10 @@ wordcraft-pdf = { path = "crates/pdf" }
# Proofing is craft-libs' craft-proof (it was crates/proof); its Language data is craft-i18n's
# language-data/, embedded from CRAFT_I18N_DIR (craft-i18n.rev). The package keeps the
# `wordcraft-proof` name here so `wordcraft_proof::` paths are unchanged.
# craft-proof is storytold/craft-libs main at the merge of #590 (ca-ES-valencia reads the ca-valencia
# pack), after #589 (French elision suggestions first) and #497 (flag-based compounding), which also
# has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# craft-proof is storytold/craft-libs main at the merge of #497 (flag-based compounding), which
# also has #496 (review follow-ups, en-AU) and the merged #482–#490/#495 stack; the font store's
# `language-pack` kind is #486 (CP4.1).
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "01315d9c92b5b0e69082d35ca56099461a1efd45", version = "=0.1.0" }
wordcraft-proof = { package = "craft-proof", git = "https://github.com/storytold/craft-libs", rev = "47c9f62530db72bc5c3831c86653b47b20c640be", version = "=0.1.0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "762a06a4c6b48b522b7d4de84a87917ef90265e6", version = "=0.1.0" }
wordcraft-engine = { path = "crates/engine" }
+22 -7
View File
@@ -11,16 +11,31 @@ _Avoid_: Display language, UI locale
**Proofing language**:
The language a piece of text is marked as, which decides how its spelling, grammar and hyphenation
are checked. A language is told apart from another by its tag (language, script, region, variant),
never by the name a list shows for it.
are checked.
_Avoid_: Text language, editing language, document language
**Language pack**:
A downloadable bundle of a language's spelling dictionary and hyphenation patterns.
_Avoid_: Dictionary download, language data (that is the embedded data)
**Language list**:
The proofing languages offered in Set Proofing Language, Review › Language and the status bar's
menu: the languages WordCraft names, plus those of every installed (and, in Set Proofing Language,
every available) Language pack, each once.
_Avoid_: Language menu (that is only one of the places it is shown)
**Your languages**:
The proofing languages Options › Language lists: each English variant in use (the default proofing
language and any in the open document), every language with an installed
Language pack, and every proofing language used in the open document.
_Avoid_: Authoring languages, preferred languages
**Default proofing language**:
The proofing language new text gets, set with Set As Default; without one, the interface language.
_Avoid_: Preferred language
**Proofing status**:
Where a language's proofing comes from: built in, an installed Language pack, your own dictionary,
available to install, or not available.
**Custom dictionary**:
The words you added with Add to Dictionary, accepted in every language.
_Avoid_: User dictionary (in the interface)
**Your own dictionaries folder**:
A folder of Hunspell dictionaries you supply, used before Language packs and the system's.
_Avoid_: Dictionaries folder, custom dictionaries
+6 -8
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
059cbf95c2a3eb95d637ca8584faca1129d17f48
+156 -30
View File
@@ -11,7 +11,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("proof.packs", "Language Packs", "File › Options › Language", list)
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on; in the app it runs in the background: see "refreshing")} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (where the data comes from), "archive" (the download), "spelling", "hyphenation", "installed"}], "installed": [{"locale", "name", "licence"}], "installing": [{"locale", "done", "total"} (bytes)], "refreshing": bool, "errors": {locale or "" (the list): message} (the last failure of each), "index_override": string|null (a test index in use instead of the pinned one), "network": bool (downloading on), "available": bool (a font store to install into)}"#)
.params(r#"{"refresh"?: bool (fetch the pinned index again; only while downloading is on; in the app it runs in the background: see "refreshing")} — opens nothing. Returns {"packs": [{"locale", "name", "size" (bytes), "licence" (SPDX), "class", "source" (where the data comes from), "archive" (the download), "spelling", "hyphenation", "installed"}], "languages": [{"tags", "pack", "installed", "size", "proofing" (built_in, installed, own_dictionary, system_dictionary, install, grammar_built_in, not_available), "default"}] (Options › Language › Your languages), "installed": [{"locale", "name", "licence"}], "installing": [{"locale", "done", "total"} (bytes)], "refreshing": bool, "errors": {locale or "" (the list): message} (the last failure of each), "index_override": string|null (a test index in use instead of the pinned one), "network": bool (downloading on), "available": bool (a font store to install into)}"#)
.pure(),
CommandSpec::new("proof.installPack", "Install Language Pack", "File › Options › Language", install)
.params(r#"{"locale": string (a pack's locale from proof.packs, like nl or pt-pt)} — downloads, verifies (sha256) and installs it; spelling and hyphenation in that language use it at once. Needs downloading on (only the person in the app can turn it on). Opens nothing. In the app it runs in the background and returns {"started": true}: follow it with proof.packs ("installing", then "installed" or "errors"); elsewhere it returns when installed."#)
@@ -33,7 +33,7 @@ fn locale_param(v: &Value) -> Result<String, CmdError> {
packs::parse_locale(raw)
}
fn list(_: &mut Session, v: &Value) -> CmdResult {
fn list(s: &mut Session, v: &Value) -> CmdResult {
if p::bool(v, "refresh") == Some(true) {
packs::start_refresh().map_err(CmdError::Failed)?;
if let Some(e) = packs::error("") {
@@ -55,6 +55,7 @@ fn list(_: &mut Session, v: &Value) -> CmdResult {
.collect();
Ok(json!({
"packs": rows,
"languages": packs::your_languages(s),
"installed": installed.iter().map(|i| json!({"locale": i.locale, "name": i.name, "licence": i.licence})).collect::<Vec<_>>(),
"installing": packs::installing(),
"refreshing": packs::fetching(),
@@ -162,6 +163,159 @@ mod tests {
});
}
/// Options › Language › Your languages: fresh, with Dutch installed, a document with Thai,
/// Turkish without a pack, and en-GB as the default.
mod your_languages {
use super::*;
use crate::packs::Proofing;
fn none(_: &str) -> Option<std::path::PathBuf> {
None
}
fn row<'a>(rows: &'a [packs::LanguageRow], tag: &str) -> &'a packs::LanguageRow {
rows.iter().find(|r| r.is_tag(tag) || r.pack.as_deref() == Some(tag)).unwrap_or_else(|| panic!("no row for {tag}: {rows:?}"))
}
fn mark(s: &mut Session, para: usize, lang: &str) {
s.run("caret.set", &json!({"pos": {"block": para, "off": 0}})).unwrap();
s.run("select.paragraph", &json!({})).unwrap();
s.run("review.language", &json!({"lang": lang})).unwrap();
}
#[test]
fn a_fresh_install_lists_only_english() {
with_backend(FixedBackend::sample(), |s, _| {
let rows = packs::your_languages_with(s, None, &none);
assert_eq!(rows.len(), 1, "{rows:?}");
assert_eq!(rows[0].proofing, Proofing::BuiltIn);
assert!(rows[0].default);
assert_eq!(rows[0].pack, None);
});
}
#[test]
fn an_installed_pack_is_a_row_with_remove() {
with_backend(FixedBackend::sample(), |s, b| {
b.network.store(true, Ordering::Relaxed);
s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
let rows = packs::your_languages_with(s, None, &none);
assert_eq!(rows.len(), 2);
let nl = row(&rows, "nl");
assert!(nl.installed && nl.tags.is_empty() && !nl.default);
assert_eq!(nl.proofing, Proofing::Installed);
let r = s.run("proof.packs", &json!({})).unwrap();
assert_eq!(r["languages"].as_array().unwrap().len(), 2, "MCP sees the same rows");
});
}
#[test]
fn document_languages_show_install_grammar_and_not_available() {
with_backend(FixedBackend::sample(), |s, b| {
s.run("document.setText", &json!({"text": "Hallo\nMerhaba\nสวัสดี\nCześć\nBonjour"})).unwrap();
mark(s, 0, "nl-NL");
mark(s, 1, "tr-TR");
mark(s, 2, "th-TH");
mark(s, 3, "pl-PL");
mark(s, 4, "fr-FR");
let rows = packs::your_languages_with(s, None, &none);
let nl = row(&rows, "nl-NL");
assert_eq!((nl.proofing, nl.size, nl.installed), (Proofing::Install, 1_950_000, false));
// Turkish has grammar rules built in and no pack in the sample index.
assert_eq!(row(&rows, "tr-TR").proofing, Proofing::NotAvailable);
assert_eq!(row(&rows, "th-TH").proofing, Proofing::NotAvailable);
let pl = row(&rows, "pl-PL");
assert_eq!((pl.proofing, pl.size), (Proofing::GrammarBuiltIn, 4_620_000));
// Installing turns Install into Installed; the document keeps the row.
b.network.store(true, Ordering::Relaxed);
s.run("proof.installPack", &json!({"locale": "nl"})).unwrap();
let rows = packs::your_languages_with(s, None, &none);
assert_eq!(row(&rows, "nl-NL").proofing, Proofing::Installed);
s.run("proof.removePack", &json!({"locale": "nl"})).unwrap();
let rows = packs::your_languages_with(s, None, &none);
assert_eq!(row(&rows, "nl-NL").proofing, Proofing::Install, "still used by the document");
});
}
#[test]
fn valencian_text_is_on_the_valencian_packs_row() {
let mut b = FixedBackend::sample();
let mut v = b.packs.first().cloned().unwrap();
v.locale = "ca-valencia".into();
v.size = 1_500_000;
b.packs.push(v);
with_backend(b, |s, b| {
s.run("document.setText", &json!({"text": "Bon dia"})).unwrap();
mark(s, 0, "ca-ES-valencia");
let rows = packs::your_languages_with(s, None, &none);
assert_eq!(row(&rows, "ca-ES-valencia").pack.as_deref(), Some("ca-valencia"), "as the bar offers it");
// Installing the Catalan pack doesn't make Valencian installed.
b.network.store(true, Ordering::Relaxed);
s.run("proof.installPack", &json!({"locale": "ca"})).unwrap();
let rows = packs::your_languages_with(s, None, &none);
let v = row(&rows, "ca-ES-valencia");
assert_eq!((v.pack.as_deref(), v.installed, v.proofing), (Some("ca-valencia"), false, Proofing::Install));
});
}
#[test]
fn the_own_folder_and_the_default_language() {
with_backend(FixedBackend::sample(), |s, _| {
s.run("document.setText", &json!({"text": "Cześć\nMerhaba"})).unwrap();
mark(s, 0, "pl-PL");
mark(s, 1, "tr-TR");
let dic = |tag: &str| {
if tag.starts_with("pl") {
Some(std::path::PathBuf::from("/mine/dicts/pl_PL.dic"))
} else if tag.starts_with("tr") {
Some(std::path::PathBuf::from("/usr/share/hunspell/tr_TR.dic"))
} else {
None
}
};
let rows = packs::your_languages_with(s, Some("/mine/dicts"), &dic);
assert_eq!(row(&rows, "pl-PL").proofing, Proofing::OwnDictionary);
assert_eq!(row(&rows, "tr-TR").proofing, Proofing::SystemDictionary);
// en-GB as the default is its own row, the default one; en-US stays if the document uses it.
s.run("review.language", &json!({"lang": "en-GB", "default": true})).unwrap();
let rows = packs::your_languages_with(s, None, &none);
let gb = row(&rows, "en-GB");
assert!(gb.default && gb.proofing == Proofing::BuiltIn);
assert_eq!(rows.iter().filter(|r| r.default).count(), 1);
});
}
#[test]
fn preferred_only_sets_the_pref_and_nothing_else() {
with_backend(FixedBackend::sample(), |s, _| {
s.run("document.setText", &json!({"text": "Hello"})).unwrap();
s.run("select.all", &json!({})).unwrap();
let (doc, sel) = (s.doc.styles.default_chr.lang.clone(), s.sel.clone());
let normal = s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone());
let rev = s.rev();
s.run("review.language", &json!({"lang": "nl-NL", "preferredOnly": true})).unwrap();
assert_eq!(s.prefs.default_language.as_deref(), Some("nl-NL"));
assert_eq!(s.doc.styles.default_chr.lang, doc);
assert_eq!(s.doc.styles.styles.iter().find(|x| x.id == "Normal").map(|x| x.chr.lang.clone()), normal);
assert_eq!(s.sel, sel);
assert_eq!(packs::document_languages(s), ["en-US"], "the text keeps its language");
assert_eq!(s.rev(), rev);
assert!(s.run("review.language", &json!({"preferredOnly": true})).is_err());
});
}
#[test]
fn hostile_tags_never_panic() {
with_backend(FixedBackend::sample(), |s, _| {
s.run("document.setText", &json!({"text": "a b"})).unwrap();
s.prefs.default_language = Some("x".repeat(10_000));
let _ = packs::your_languages_with(s, Some(""), &none);
s.prefs.default_language = Some("\u{0}-\u{202e}".into());
let _ = packs::your_languages_with(s, None, &none);
});
}
}
#[test]
fn unknown_and_hostile_locales_are_errors() {
with_backend(FixedBackend::sample(), |s, b| {
@@ -501,34 +655,6 @@ mod tests {
assert_eq!(messages(pt_pt), messages(ProofLang::Pt), "Portugal text gets the Portuguese grammar checks");
}
/// The one region test and region-dropping step the engine and the UI share: two letters or
/// three digits after the language; hostile text never panics.
#[test]
fn a_region_is_two_letters_or_three_digits() {
for r in ["NL", "nl", "419", "ZZ"] {
assert!(packs::is_region(r), "{r}");
}
for r in ["", "n", "nld", "4190", "41", "Latn", "valencia", "é1", "\u{202e}\u{202e}"] {
assert!(!packs::is_region(r), "{r:?}");
}
for (tag, bare) in [
("nl-NL", "nl"),
("sr-Latn-RS", "sr-Latn"),
("ca-ES-valencia", "ca-valencia"),
("es_419", "es"),
("pt", "pt"),
("en-US-x-test", "en-x-test"),
("", ""),
("-", "-"),
] {
assert_eq!(packs::without_region(tag), bare, "{tag}");
}
// The language itself is never a region, even when it looks like one.
assert_eq!(packs::without_region("de"), "de");
let _ = packs::without_region(&"-".repeat(10_000));
let _ = packs::without_region("\u{0}-\u{202e}");
}
/// A font store that has never fetched the list (a fresh install), or whose cached list is
/// for an older pin: `index` has nothing until a fetch.
struct Uncached(FixedBackend);
+8
View File
@@ -12,6 +12,14 @@ pub(super) fn language(s: &mut Session, v: &Value) -> CmdResult {
),
None => None,
};
// Options › Language › Set as default: only the saved default proofing language (what future
// blank documents are written in), as Word's Set as Preferred; neither this document nor
// the selection changes.
if p::bool(v, "preferredOnly") == Some(true) {
let l = lang.ok_or_else(|| CmdError::Params("`preferredOnly` needs `lang`".into()))?;
s.prefs.default_language = Some(l.clone());
return Ok(json!({"preferred": l}));
}
let no_proof = p::bool(v, "noProof");
let detect = p::bool(v, "detect");
if let Some(d) = detect {
+1 -1
View File
@@ -337,7 +337,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), "preferredOnly"?: bool (with `lang`: only the saved default proofing language for future blank documents, as Options › Language › Set as default does; the open document and the selection don't change)}"#,
),
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
+167 -18
View File
@@ -589,20 +589,6 @@ pub fn pack_for_tag<'a>(tag: &str, packs: &'a [Pack]) -> Option<&'a Pack> {
pack_candidates(tag).iter().find_map(|c| packs.iter().find(|p| &p.locale == c))
}
/// Is `subtag` a region, as it follows a tag's language (and script): two letters (`NL`) or
/// three digits (`419`)? The one test of what a region is; the lists, the menus and
/// [`pack_for_tag`] all use it.
pub fn is_region(subtag: &str) -> bool {
(subtag.len() == 2 && subtag.bytes().all(|b| b.is_ascii_alphabetic())) || (subtag.len() == 3 && subtag.bytes().all(|b| b.is_ascii_digit()))
}
/// `tag` without its region subtags (`nl-NL` is `nl`, `sr-Latn-RS` is `sr-Latn`, `ca-ES-valencia`
/// is `ca-valencia`), joined with `-` whichever of `-` and `_` separated them; the case is kept.
/// The language itself (the first subtag) is never a region.
pub fn without_region(tag: &str) -> String {
tag.split(['-', '_']).enumerate().filter(|(i, p)| *i == 0 || !is_region(p)).map(|(_, p)| p).collect::<Vec<&str>>().join("-")
}
/// The pack locales [`pack_for_tag`] tries for `tag`, best first.
fn pack_candidates(tag: &str) -> Vec<String> {
// A tag is a handful of short subtags; anything longer is junk and only its start is read.
@@ -612,12 +598,23 @@ fn pack_candidates(tag: &str) -> Vec<String> {
if base.is_empty() || base == "en" {
return Vec::new();
}
let region = parts
.iter()
.enumerate()
.skip(1)
.find(|(_, p)| (p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic())) || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit())));
let mut out: Vec<String> = Vec::new();
for n in (1..=parts.len()).rev() {
let prefix = parts.get(..n).unwrap_or_default().join("-");
// The prefix, then without its region: the variant's pack before the region alone.
let stripped = without_region(&prefix);
for c in [prefix, stripped] {
let prefix = parts.get(..n).unwrap_or_default();
for c in [
Some(prefix.join("-")),
region
.filter(|(r, _)| r + 1 < n)
.map(|(r, _)| prefix.iter().enumerate().filter(|(i, _)| *i != r).map(|(_, p)| *p).collect::<Vec<&str>>().join("-")),
]
.into_iter()
.flatten()
{
if !out.contains(&c) {
out.push(c);
}
@@ -757,6 +754,158 @@ pub fn missing(s: &Session) -> Vec<Pack> {
out
}
/// Where a language's proofing comes from (Options › Language › Your languages).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Proofing {
/// English: the embedded dictionaries.
BuiltIn,
/// An installed Language pack.
Installed,
/// A Hunspell dictionary in the person's own dictionaries folder.
OwnDictionary,
/// A Hunspell dictionary the system has.
SystemDictionary,
/// No dictionary yet; a Language pack can be installed.
Install,
/// Grammar and hyphenation rules built in; a Language pack adds spelling.
GrammarBuiltIn,
/// No dictionary and no pack for it.
NotAvailable,
}
/// A row of Options › Language › Your languages.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct LanguageRow {
/// The tags the default proofing language or the open document use for this language (the
/// first is what Set as default sets); empty for a language listed for its installed pack.
pub tags: Vec<String>,
/// The pack locale: the installed pack, else the one the index offers.
pub pack: Option<String>,
/// Is the pack installed?
pub installed: bool,
/// Bytes of the offered pack (0 when the index has none).
pub size: u64,
pub proofing: Proofing,
/// Is this the default proofing language?
pub default: bool,
}
impl LanguageRow {
/// Is `tag` (the insertion point's language) one of this row's languages?
pub fn is_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
}
}
/// The first locale `has` accepts for `tag`, in [`pack_for_tag`]'s order. `None` for English and junk.
fn locale_for_tag(tag: &str, has: &dyn Fn(&str) -> bool) -> Option<String> {
pack_candidates(tag).into_iter().find(|c| has(c))
}
/// [`your_languages_with`] against the dictionaries on this computer.
pub fn your_languages(s: &Session) -> Vec<LanguageRow> {
let folder = s.prefs.dictionaries_folder.clone();
your_languages_with(s, folder.as_deref(), &dictionary_file)
}
/// The Hunspell `.dic` file proofing reads for `tag`'s language now (a built-in-rules language's
/// own, or a pack language's), if any.
fn dictionary_file(tag: &str) -> Option<PathBuf> {
let lang = wordcraft_proof::ProofLang::from_tag(Some(tag));
if let Some((_, f)) = wordcraft_proof::installed_dictionaries().into_iter().find(|(l, _)| *l == lang) {
return f;
}
wordcraft_proof::pack_dictionary_file(lang)
}
/// Options › Language › Your languages (unsorted; the window names and sorts them): each English
/// variant in use (the default proofing language, American English and any in the open document), every language
/// with an installed Language pack, and every proofing language of the open document. One row per
/// language: tags one pack proofs share a row. `folder` is the person's own dictionaries folder;
/// `dictionary` finds the `.dic` file proofing reads for a tag.
pub fn your_languages_with(s: &Session, folder: Option<&str>, dictionary: &dyn Fn(&str) -> Option<PathBuf>) -> Vec<LanguageRow> {
let index = available();
let installed = installed();
let default_tag = s
.prefs
.default_language
.clone()
.or_else(|| s.editing_lang.clone())
.or_else(|| s.doc.styles.default_chr.lang.clone())
.unwrap_or_else(|| "en-US".to_string());
// American English stays listed whatever the default is, so it can always be set back.
let mut tags = vec![default_tag.clone(), "en-US".to_string()];
tags.dedup_by(|a, b| a.eq_ignore_ascii_case(b));
for t in document_languages(s) {
if !tags.iter().any(|x| x.eq_ignore_ascii_case(&t)) {
tags.push(t);
}
}
let mut rows: Vec<LanguageRow> = Vec::new();
let mut add = |tag: Option<&str>, locale: Option<String>| {
let english = tag.is_some_and(|t| wordcraft_proof::ProofLang::from_tag(Some(t)).is_english());
// English variants stay apart (en-GB and en-US are two rows); a pack's tags share one.
let existing = rows.iter().position(|r| match (&locale, &r.pack) {
(Some(l), Some(p)) if !english => l == p,
_ => tag.is_some_and(|t| r.is_tag(t)),
});
let i = existing.unwrap_or_else(|| {
rows.push(LanguageRow {
tags: Vec::new(),
pack: locale.clone(),
installed: false,
size: 0,
proofing: Proofing::NotAvailable,
default: false,
});
rows.len() - 1
});
if let (Some(t), Some(row)) = (tag, rows.get_mut(i))
&& !row.is_tag(t)
{
row.tags.push(t.to_string());
}
};
for t in &tags {
if !proofable(t) {
continue;
}
// The most specific pack, installed or offered, as the bar picks it ([`pack_for_tag`]): with
// `ca` installed, Valencian text is still the `ca-valencia` pack's row.
let locale = locale_for_tag(t, &|l| installed.iter().any(|i| i.locale == l) || index.iter().any(|p| p.locale == l));
add(Some(t), locale);
}
for i in &installed {
add(None, Some(i.locale.clone()));
}
for row in &mut rows {
let installed_here = row.pack.as_ref().is_some_and(|l| installed.iter().any(|i| &i.locale == l));
let offered = row.pack.as_ref().and_then(|l| index.iter().find(|p| &p.locale == l));
row.installed = installed_here;
row.size = offered.map_or(0, |p| p.size);
row.default = row.is_tag(&default_tag);
let tag = row.tags.first().cloned().or_else(|| row.pack.clone()).unwrap_or_default();
let lang = wordcraft_proof::ProofLang::from_tag(Some(&tag));
let file = if lang.is_english() { None } else { dictionary(&tag) };
let own = file.as_ref().is_some_and(|f| folder.is_some_and(|d| !d.trim().is_empty() && f.starts_with(d.trim())));
row.proofing = if lang.is_english() {
Proofing::BuiltIn
} else if own {
Proofing::OwnDictionary
} else if installed_here {
Proofing::Installed
} else if file.is_some() {
Proofing::SystemDictionary
} else if offered.is_some() {
if matches!(lang, wordcraft_proof::ProofLang::Pack(_)) { Proofing::Install } else { Proofing::GrammarBuiltIn }
} else {
Proofing::NotAvailable
};
}
rows
}
/// The index was fetched this session (since the backend was set): what it lists is then the
/// whole answer, even when it lists nothing.
static FETCHED: AtomicBool = AtomicBool::new(false);
+1 -48
View File
@@ -206,11 +206,6 @@ pub(crate) struct Alphas {
/// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to
/// the bytes, as [`crate::LayoutCache`]'s outlines do.
seen: HashMap<(usize, usize), Option<Arc<Alpha>>>,
/// Pictures whose cost was charged for a lasting outline found in [`crate::LayoutCache`]
/// ([`Alphas::charge`]) without decoding them in this layout.
charged: HashSet<(usize, usize)>,
/// Pixels each picture took of the budget (also when it then failed to decode), by bytes.
costs: HashMap<(usize, usize), u64>,
/// Pictures refused for the budget (bytes' address and length), not looked at again.
refused: HashSet<(usize, usize)>,
/// Pixels decoded so far.
@@ -228,16 +223,9 @@ impl Alphas {
Some(alpha) => alpha.clone(),
None if self.refused.contains(&key) => return Err(OverBudget),
None => {
// A picture already charged for (its outline was cached) isn't charged twice.
let mut free = 0u64;
let paid = self.charged.contains(&key);
let before = self.decoded;
let alpha = decode_alpha(bytes, if paid { &mut free } else { &mut self.decoded }).inspect_err(|_| {
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
self.refused.insert(key);
})?;
if !paid {
self.costs.insert(key, self.decoded.saturating_sub(before));
}
let alpha = alpha.map(Arc::new);
self.seen.insert(key, alpha.clone());
alpha
@@ -245,41 +233,6 @@ impl Alphas {
};
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
}
/// The pixels picture `bytes` took of the budget when it was decoded in this layout (0:
/// not decoded, or none to look at; a picture that failed to decode still cost its pixels).
pub(crate) fn cost(&self, bytes: &[u8]) -> u64 {
self.costs.get(&(bytes.as_ptr() as usize, bytes.len())).copied().unwrap_or(0)
}
/// Charges the budget for picture `bytes`, whose outline was found kept from an earlier
/// layout (`cost`: the pixels it took then), as decoding it now would: pictures get an outline
/// by their place in the document, not by what the cache holds. [`OverBudget`] when it
/// wouldn't have fitted: its rectangle is its outline in this layout.
pub(crate) fn charge(&mut self, bytes: &[u8], cost: u64) -> Result<(), OverBudget> {
let key = (bytes.as_ptr() as usize, bytes.len());
if self.seen.contains_key(&key) || self.charged.contains(&key) {
return Ok(());
}
if self.refused.contains(&key) {
return Err(OverBudget);
}
if cost == 0 {
return Ok(());
}
match self.decoded.checked_add(cost).filter(|t| *t <= MAX_DECODED_PIXELS) {
Some(total) => {
self.decoded = total;
self.charged.insert(key);
self.costs.insert(key, cost);
Ok(())
}
None => {
self.refused.insert(key);
Err(OverBudget)
}
}
}
}
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
+3 -13
View File
@@ -69,9 +69,6 @@ pub(crate) struct Ctx<'a> {
pub(crate) hf: Arc<HfLinks<'a>>,
/// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]).
pub(crate) alphas: contour::Alphas,
/// Items re-placed as repeated table header rows so far, in the whole layout (see
/// [`MAX_HEADER_ITEMS`]).
pub(crate) header_repeats: usize,
}
struct ParaEnvInput<'a> {
@@ -147,14 +144,10 @@ pub(crate) fn with_pin<T>(
/// an address names the same bytes only while their allocation lives: the weak reference keeps
/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address
/// and length and be taken for it.
///
/// Each also holds the pixels its picture took of a layout's decoding budget, which a layout
/// finding the entry charges as if it had decoded the picture ([`contour::Alphas::charge`]).
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>, u64)>;
pub(crate) type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this
/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it.
/// One kept from before is charged to the budget as if decoded now.
pub(crate) fn picture_outline(
outlines: &mut Outlines,
alphas: &mut contour::Alphas,
@@ -162,17 +155,14 @@ pub(crate) fn picture_outline(
bytes: &Arc<Vec<u8>>,
crop: [f32; 4],
) -> Option<Arc<Vec<(f32, f32)>>> {
if let Some((_, o, cost)) = outlines.get(&key) {
// Kept from an earlier layout, but it still costs this one's budget: the same pictures
// get outlines whatever the cache holds.
alphas.charge(bytes, *cost).ok()?;
if let Some((_, o)) = outlines.get(&key) {
return o.clone();
}
let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new);
if outlines.len() >= 256 {
outlines.clear();
}
outlines.insert(key, (Arc::downgrade(bytes), outline.clone(), alphas.cost(bytes)));
outlines.insert(key, (Arc::downgrade(bytes), outline.clone()));
outline
}
+1 -54
View File
@@ -109,64 +109,11 @@ impl Linked {
/// Header rows are re-placed on every page a table spills onto, so a table is O(header items ×
/// pages) placed items — and an exact-height header row keeps all its overflowing text without
/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row
/// counts at least one) have been repeated across the whole layout (every table together), later pages carry no header rows;
/// counts at least one) have been repeated across a table, later pages carry no header rows;
/// pagination still finishes. Far above any real table (three ordinary header rows over some
/// 5 000 pages).
pub(crate) const MAX_HEADER_ITEMS: usize = 500_000;
#[cfg(test)]
thread_local! {
/// A smaller [`MAX_HEADER_ITEMS`] for tests, so a short table can reach it.
pub(crate) static HEADER_ITEMS_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_HEADER_ITEMS) };
}
/// [`MAX_HEADER_ITEMS`], or the test cap ([`HEADER_ITEMS_CAP`]) in tests.
pub(crate) fn header_items_cap() -> usize {
#[cfg(test)]
return HEADER_ITEMS_CAP.with(|c| c.get());
#[cfg(not(test))]
MAX_HEADER_ITEMS
}
/// What re-placing `it` costs against [`MAX_HEADER_ITEMS`] (and laying it out against
/// [`textbox::MAX_FLOW_WORK`]): one, and for a clipped group (a floating Drawing Canvas) every
/// member below it, nested groups included, as placing clones them all. Counted with an explicit
/// stack, and counting stops once the total passes `limit` (the result is then just over it), so
/// neither deep nesting nor a huge group makes the count itself costly.
pub(crate) fn placed_cost(it: &Placed, limit: usize) -> usize {
placed_cost_by(it, limit, |_| 1)
}
/// [`placed_cost`] with each item (the group itself and every member) costing `cost` of it.
pub(crate) fn placed_cost_by(it: &Placed, limit: usize, cost: impl Fn(&Placed) -> usize) -> usize {
let mut count = 0usize;
let mut stack: Vec<std::slice::Iter<Placed>> = Vec::new();
let mut next = Some(it);
loop {
let cur = match next.take() {
Some(c) => c,
None => loop {
match stack.last_mut() {
None => return count,
Some(iter) => match iter.next() {
Some(c) => break c,
None => {
stack.pop();
}
},
}
},
};
count = count.saturating_add(cost(cur));
if count > limit {
return count;
}
if let Placed::Clip { items, .. } = cur {
stack.push(items.iter());
}
}
}
/// Header tables can displace a non-overlapping body table. Page-positioned drawings keep
/// their own wrapping rules, so only table areas are retained from the header layout.
pub(crate) fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
-3
View File
@@ -66,9 +66,6 @@ pub use floats::{is_floating, shape_figure_alt};
use floats::{left_out_objects, wrap_area};
#[cfg(test)]
use headers::MAX_HEADER_ITEMS;
#[cfg(test)]
use headers::placed_cost;
use headers::placed_cost_by;
pub use notes::DECOR_STORY;
pub(crate) use notes::NOTE_SEP_W;
use notes::{MAX_NOTE_PASSES, footnote_pages};
-4
View File
@@ -109,7 +109,6 @@ fn new_ctx<'a>(
floats_above: HashMap::new(),
hf: Arc::new(HfLinks::new(doc.sections())),
alphas: Default::default(),
header_repeats: 0,
}
}
@@ -561,7 +560,6 @@ struct Snap<'a> {
eq_count: u32,
page_field: u32,
boxes: usize,
header_repeats: usize,
}
impl<'a> Snap<'a> {
@@ -579,7 +577,6 @@ impl<'a> Snap<'a> {
eq_count: ctx.eq_count,
page_field: ctx.fields.page,
boxes: ctx.boxes.total(),
header_repeats: ctx.header_repeats,
}
}
@@ -602,7 +599,6 @@ impl<'a> Snap<'a> {
ctx.counters = self.counters.clone();
ctx.eq_count = self.eq_count;
ctx.fields.page = self.page_field;
ctx.header_repeats = self.header_repeats;
}
}
+9 -13
View File
@@ -2,7 +2,7 @@ use super::boxes::{BoxInput, clear_of_float_tables, layout_box};
use super::ctx::{Ctx, with_pct_base};
use super::floats::{WrapArea, align_in, body_wraps};
use super::flow::again;
use super::headers::{header_items_cap, header_table_areas, placed_cost};
use super::headers::{MAX_HEADER_ITEMS, header_table_areas};
use super::notes::table_notes;
use super::page::{PageBuilder, PageFrame, page_frame};
use super::placed::Placed;
@@ -127,14 +127,10 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
// A table in the text flow starts below the floating objects in its way.
below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
// What one more repeat of the header rows costs (see `MAX_HEADER_ITEMS`; the items repeated
// so far are counted in the whole layout).
let header_cap = header_items_cap();
let header_cost = header_rows
.iter()
.filter_map(|i| tl.rows.get(*i))
.map(|r| r.items.iter().map(|it| placed_cost(it, header_cap)).fold(0, usize::saturating_add).max(1))
.fold(0, usize::saturating_add);
// Items repeated so far, and what one more repeat of the header rows costs (see
// `MAX_HEADER_ITEMS`).
let mut header_repeats = 0usize;
let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add);
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
@@ -184,7 +180,7 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
let table_at_top = pb.at_top();
for (ri, row) in tl.rows.iter().enumerate() {
// Word never splits a row of an exact height: it moves on whole.
let splittable = ri >= header_rows.len()
let splittable = !header_rows.contains(&ri)
&& !t
.rows
.get(ri)
@@ -241,9 +237,9 @@ pub(crate) fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc
None => {}
}
pb.advance(block, body_top);
// Repeat header rows, up to the layout's cap.
if ri >= header_rows.len() && ctx.header_repeats.saturating_add(header_cost) <= header_cap {
ctx.header_repeats = ctx.header_repeats.saturating_add(header_cost);
// Repeat header rows, up to this table's cap.
if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS {
header_repeats += header_cost;
for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) {
place(pb, h);
}
+8 -355
View File
@@ -1,7 +1,6 @@
//! Hostile documents whose layout work or memory would otherwise grow far past their size: each
//! is bounded by a cap, and real documents stay well under it.
use crate::ctx::Outlines;
use crate::*;
use wordcraft_doc::props::HeightRule;
use wordcraft_doc::table::{MAX_COLS, Table};
@@ -37,11 +36,10 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
}
#[test]
fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
// Forty linked boxes, each a line deep and each a different width, over a long story: every
// width would lay the whole story out again. Past the work cap, later boxes reuse the
// nearest width already laid out, so every box still shows its share of the story.
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
// width would lay the whole story out again. Past the cap, later boxes reuse the nearest
// width already laid out, so every box still shows its share of the story.
let n = 40;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
@@ -58,85 +56,7 @@ fn a_linked_chain_lays_its_story_out_for_a_bounded_amount_of_work() {
lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect();
widths.sort_unstable();
widths.dedup();
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
// The cap is spent after the first few layouts (each is far over 2000 / 8 work).
assert!(widths.len() <= 8, "{} widths laid the story out", widths.len());
}
#[test]
fn twenty_distinct_widths_of_an_ordinary_story_all_keep_their_width() {
// A real chain of 20 boxes, 100, 105, ... 195 pt wide: each is laid out at its own width.
let n = 20;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 5.0 * i as f32, 28.0)).collect();
for w in ids.windows(2) {
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
}
let mut widths = story_layouts(&lay(&d), ids[0]);
assert_eq!(widths.len(), n, "every box shows the story");
widths.sort_unstable();
widths.dedup();
assert_eq!(widths.len(), n, "every box has a layout at its own width");
}
/// How many times story layouts ran while `doc` was laid out with the chain work cap at `cap`.
fn chain_layouts(d: &Document, cap: usize) -> usize {
textbox::FLOW_WORK_CAP.with(|c| c.set(cap));
textbox::FLOW_LAYOUTS.with(|c| c.set(0));
lay(d);
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
textbox::FLOW_LAYOUTS.with(|c| c.get())
}
/// `n` linked boxes `w0`, `w0 + 0.1`, ... pt wide (each its own layout) and a line deep, the
/// first holding `blocks`; the first box's id.
fn hairline_chain(d: &mut Document, n: usize, w0: f32, blocks: Vec<Arc<Block>>) -> u32 {
let ids: Vec<u32> = (0..n).map(|i| text_box(d, i, "", w0 + 0.1 * i as f32, 28.0)).collect();
for w in ids.windows(2) {
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
}
d.parts.get_mut(&ids[0]).unwrap().blocks = blocks;
ids[0]
}
#[test]
fn a_chain_whose_story_shows_little_still_pays_for_its_text() {
// All 256 boxes a hair apart in width, each showing one short visible line of a story that
// hides about 1 MB of text between them: each layout gives some 300 lines but works through
// the whole story. Charged by its lines alone, every width was laid out (256 times the story).
let n = wordcraft_doc::MAX_LINKED_BOXES;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let hidden = "Hidden words that never show. ".repeat(3500 / 30);
let hide = wordcraft_doc::props::CharProps { hidden: Some(true), ..Default::default() };
let story = (0..300)
.map(|k| {
let shown = format!("Line {k}.");
let mut p = Paragraph::with_text(&format!("{shown}{hidden}"), Default::default());
p.runs = vec![
wordcraft_doc::para::Run { len: shown.len(), props: Default::default() },
wordcraft_doc::para::Run { len: hidden.len(), props: hide.clone() },
];
p.touch();
para_block(p)
})
.collect();
hairline_chain(&mut d, n, 300.0, story);
// Ordinary stories of this many lines keep all 256 widths under this cap.
let layouts = chain_layouts(&d, 100_000);
assert!(layouts <= 8, "{layouts} layouts of a 1 MB story");
}
#[test]
fn a_chain_of_wide_boxes_pays_for_a_long_paragraph() {
// 256 wide boxes a hair apart over one long paragraph: few lines per layout, but each
// layout shapes all of it.
let n = wordcraft_doc::MAX_LINKED_BOXES;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(64 * 1024 / 58);
hairline_chain(&mut d, n, 1500.0, vec![para_block(Paragraph::with_text(&story, Default::default()))]);
let layouts = chain_layouts(&d, 2000);
assert!(layouts <= 4, "{layouts} layouts of a 64 KB story");
assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len());
}
/// The paragraph layouts (by address) showing story `head` across the document.
@@ -175,8 +95,7 @@ fn stacked_boxes_of_any_width_share_a_layout() {
fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
// The cap is full of horizontal widths when a vertical box comes: it lays the story out
// its own way rather than showing a horizontal layout.
textbox::FLOW_WORK_CAP.with(|c| c.set(2000));
let n = 17;
let n = textbox::MAX_FLOW_WIDTHS + 1;
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
let story = "Words that run on and on through every box of the chain. ".repeat(200);
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
@@ -190,12 +109,7 @@ fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
assert_eq!(widths.len(), n, "every box shows the story");
widths.sort_unstable();
widths.dedup();
assert!(widths.len() > 1 && widths.len() < n, "{} layouts: the vertical box has its own, the rest are shared", widths.len());
// The vertical box's layout is none of the horizontal boxes', still past the cap.
let mut all = story_layouts(&lay(&d), ids[0]);
textbox::FLOW_WORK_CAP.with(|c| c.set(textbox::MAX_FLOW_WORK));
let vertical = all.pop();
assert!(vertical.is_some_and(|v| !all.contains(&v)), "the vertical box shares a horizontal layout");
assert_eq!(widths.len(), n, "the vertical box has a layout of its own");
}
#[test]
@@ -362,7 +276,7 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() {
assert!(outlines.is_empty(), "the refusal isn't kept");
let mut fresh = contour::Alphas::default();
assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some());
assert!(outlines.get(&key).is_some_and(|(_, o, _)| o.is_some()), "the outline is kept");
assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept");
}
/// The outline cache outlives the documents it is used with and tells pictures apart by their
@@ -374,270 +288,9 @@ fn a_kept_outline_holds_on_to_its_pictures_allocation() {
let mut outlines = HashMap::new();
let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]);
assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some());
let Some((weak, ..)) = outlines.get(&key) else { panic!("the outline is kept") };
let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") };
assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes");
assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes");
drop(disc);
assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept");
}
/// A floating Drawing Canvas in a header cell is one `Clip` holding its members, and re-placing
/// the header clones every one of them: they all count against the repeat budget.
#[test]
fn a_clipped_group_costs_its_members_when_a_header_repeats() {
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
let big = usize::MAX;
assert_eq!(placed_cost(&rule(), big), 1);
assert_eq!(placed_cost(&clip((0..1000).map(|_| rule()).collect()), big), 1001);
// Nested groups count too, however deep: every descendant is charged.
assert_eq!(placed_cost(&clip(vec![clip(vec![rule(), rule()]), rule()]), big), 5);
let mut deep = clip((0..1000).map(|_| rule()).collect());
for _ in 0..20 {
deep = clip(vec![deep]);
}
assert_eq!(placed_cost(&deep, big), 1021, "members below the eighth level still cost one each");
// Counting stops once past the limit, so a huge or very deep group is cheap to count.
assert_eq!(placed_cost(&deep, 10), 11);
let mut deeper = rule();
for _ in 0..100_000 {
deeper = clip(vec![deeper]);
}
assert_eq!(placed_cost(&deeper, 50), 51);
assert_eq!(placed_cost(&deeper, big), 100_001);
std::mem::forget(deeper);
}
/// The linked-box work budget counts a clipped group's members, nested groups included.
#[test]
fn flow_work_counts_clip_members() {
let rule = || Placed::Rule { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.0, border: Default::default() };
let clip = |items: Vec<Placed>| Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items };
let mut deep = clip((0..100).map(|_| rule()).collect());
for _ in 0..12 {
deep = clip(vec![deep, rule()]);
}
assert_eq!(textbox::flow_work(&[rule(), deep]), 1 + 100 + 1 + 12 * 2);
}
/// Lines inside a clipped group (a text box on a canvas) cost each line, as they do outside one.
#[test]
fn flow_work_counts_each_line_in_a_clip() {
let l = lay(&Document::from_text(&"Words that run on and on. ".repeat(200)));
let lines = l.pages.iter().flat_map(|p| &p.items).find(|it| matches!(it, Placed::Lines { .. })).cloned().unwrap();
let (l0, l1) = match &lines {
Placed::Lines { l0, l1, .. } => (*l0, *l1),
_ => (0, 0),
};
assert!(l1 - l0 > 1);
let clip = Placed::Clip { rect: Rect::new(0.0, 0.0, 10.0, 10.0), spin: Default::default(), items: vec![lines] };
assert_eq!(textbox::flow_work(&[clip]), 1 + (l1 - l0));
}
#[test]
fn repeated_header_items_are_capped_across_all_the_tables_of_a_layout() {
// Each table's repeats used to have a budget of their own: a hundred tables, a hundred times
// the cap.
let table = || {
let mut t = Table::new(201, 1, 468.0);
t.rows[0].props.header = true;
t.rows[0].props.height = Some(20.0);
t.rows[0].props.height_rule = HeightRule::Exact;
t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect();
for row in t.rows.iter_mut().skip(1) {
row.props.height = Some(600.0);
row.props.height_rule = HeightRule::Exact;
}
t
};
let mut d = Document::from_text("");
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(table())).unwrap();
d.insert_block(StoryRef::Body, &Path::top(2), Block::Table(table())).unwrap();
d.insert_block(StoryRef::Body, &Path::top(3), Block::Table(table())).unwrap();
let l = lay(&d);
let items: usize = l.pages.iter().map(|p| p.items.len()).sum();
assert!(items <= MAX_HEADER_ITEMS + 3 * 3000 + 50 * l.pages.len(), "{items} items placed");
}
/// Which pictures wrap by their outline must not depend on what the cache already holds: a
/// picture's outline kept from an earlier layout is charged to the budget like a decoded one.
#[test]
fn cold_and_warm_caches_give_the_same_outlines() {
let (a, b) = (Arc::new(disc_png(64)), Arc::new(disc_png(64)));
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
// Room for one of the two pictures.
let pass = |outlines: &mut Outlines| {
let mut alphas = contour::Alphas::default();
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64;
let got = [(&a, "a"), (&b, "b")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
(got, alphas.decoded)
};
let mut roomy = contour::Alphas::default();
let mut warm = Outlines::new();
for (p, n) in [(&a, "a"), (&b, "b")] {
assert!(picture_outline(&mut warm, &mut roomy, key(p, n), p, [0.0; 4]).is_some());
}
assert_eq!(warm.len(), 2, "both outlines are kept");
let (cold, cold_spent) = pass(&mut Outlines::new());
assert_eq!(cold, [true, false], "the first picture fits, the second wraps as its rectangle");
let (warmed, warm_spent) = pass(&mut warm);
assert_eq!(warmed, cold, "the same pictures get outlines with the cache warm");
assert_eq!(warm_spent, cold_spent);
// And again: a layout after a layout.
assert_eq!(pass(&mut warm).0, cold);
}
/// A PNG whose header says `side` × `side` RGBA and whose body is missing: it has transparency
/// to look at, costs its pixels, and then fails to decode.
fn truncated_png(side: u32) -> Vec<u8> {
fn crc(data: &[u8]) -> u32 {
let mut c = !0u32;
for b in data {
c ^= u32::from(*b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
let mut ihdr = b"IHDR".to_vec();
ihdr.extend(side.to_be_bytes());
ihdr.extend(side.to_be_bytes());
ihdr.extend([8, 6, 0, 0, 0]);
let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
png.extend(&ihdr);
png.extend(crc(&ihdr).to_be_bytes());
// An image data chunk of junk: the header is read, the pixels aren't there.
let mut idat = b"IDAT".to_vec();
idat.extend([0x78, 0x9c, 1, 2, 3, 4]);
png.extend(6u32.to_be_bytes());
png.extend(&idat);
png.extend(crc(&idat).to_be_bytes());
png
}
#[test]
fn a_truncated_picture_costs_the_same_cold_and_warm() {
// Its header promises transparency, so a layout spends its pixels before the body fails to
// decode; a layout finding its (empty) outline kept spends them too, or pictures after it
// would get outlines only with the cache warm.
let (big, disc) = (Arc::new(truncated_png(8000)), Arc::new(disc_png(64)));
let key = |bytes: &Arc<Vec<u8>>, n: &str| (n.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), [0; 4]);
let pass = |outlines: &mut Outlines| {
let mut alphas = contour::Alphas::default();
alphas.decoded = contour::MAX_DECODED_PIXELS - 8000 * 8000;
let got = [(&big, "big"), (&disc, "disc")].map(|(p, n)| picture_outline(outlines, &mut alphas, key(p, n), p, [0.0; 4]).is_some());
(got, alphas.decoded)
};
let mut warm = Outlines::new();
let (cold, spent) = pass(&mut warm);
assert_eq!(spent, contour::MAX_DECODED_PIXELS, "the truncated picture spent its pixels");
assert_eq!(cold, [false, false], "no budget is left for the disc");
assert_eq!(warm.get(&key(&big, "big")).map(|e| e.2), Some(8000 * 8000), "the entry holds the cost");
// Also with a roomy first layout filling the cache, then a tight one.
let mut roomy_cache = Outlines::new();
let mut roomy = contour::Alphas::default();
for (p, n) in [(&big, "big"), (&disc, "disc")] {
let _ = picture_outline(&mut roomy_cache, &mut roomy, key(p, n), p, [0.0; 4]);
}
assert_eq!(pass(&mut roomy_cache), (cold, spent), "warm matches cold");
}
#[test]
fn a_header_row_holding_a_canvas_repeats_within_the_budget() {
use wordcraft_doc::para::{Anchor, CanvasStyle, Float, GroupChild, ShapeKind, Wrap};
// A canvas of 800 members in an exact-height header row, over two thousand pages: each page
// would clone all 800 members.
let member = InlineObject::Shape {
kind: ShapeKind::Rectangle,
w: 4.0,
h: 4.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Float::default(),
story: None,
effects: Default::default(),
freeform: None,
extra: Default::default(),
};
let canvas = InlineObject::Group {
w: 40.0,
h: 40.0,
float: Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Column, v_rel: Anchor::Paragraph, x: 5.0, y: 5.0, ..Default::default() },
ch_w: 40.0,
ch_h: 40.0,
children: (0..800).map(|_| GroupChild { x: 1.0, y: 1.0, obj: member.clone() }).collect(),
canvas: Some(CanvasStyle { fill: None, stroke: None, stroke_width: 0.0 }),
};
let mut t = Table::new(2001, 1, 468.0);
t.rows[0].props.header = true;
t.rows[0].props.height = Some(60.0);
t.rows[0].props.height_rule = HeightRule::Exact;
let mut p = Paragraph::with_text("", Default::default());
p.insert_object(0, canvas, &Default::default()).unwrap();
t.rows[0].cells[0].blocks = vec![para_block(p)];
for row in t.rows.iter_mut().skip(1) {
row.props.height = Some(600.0);
row.props.height_rule = HeightRule::Exact;
}
fn count(it: &Placed) -> usize {
match it {
Placed::Clip { items, .. } => items.iter().map(count).fold(1, usize::saturating_add),
_ => 1,
}
}
let mut d = Document::from_text("");
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let l = lay(&d);
assert!(l.pages.len() >= 1000, "{} pages", l.pages.len());
let items: usize = l.pages.iter().flat_map(|p| &p.items).map(count).sum();
assert!(items <= MAX_HEADER_ITEMS + 2000 + 50 * l.pages.len(), "{items} items placed");
assert!(items > 100_000, "the header still repeats on early pages: {items}");
}
/// A table with a repeating header row in a two-column section ended by a continuous break, so
/// its columns are balanced: laid out once, then again from a snapshot for each balancing try.
fn balanced_header_table(rows: usize) -> Document {
use wordcraft_doc::section::{SectionProps, SectionStart};
let mut d = Document::from_text("Before\nEnd of the section\nAfter the break");
let mut s = SectionProps::default();
s.columns.count = 2;
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(s));
d.last_section.start = SectionStart::Continuous;
let mut t = Table::new(rows, 1, 200.0);
t.rows[0].props.header = true;
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
d
}
/// Header rows placed (each repeat of the one-column header row is one cell of row 0).
fn header_cells(l: &DocLayout) -> usize {
l.pages.iter().flat_map(|p| &p.items).filter(|i| matches!(i, Placed::Cell { row: 0, .. })).count()
}
fn with_header_cap<T>(cap: usize, f: impl FnOnce() -> T) -> T {
headers::HEADER_ITEMS_CAP.with(|c| c.set(cap));
let out = f();
headers::HEADER_ITEMS_CAP.with(|c| c.set(MAX_HEADER_ITEMS));
out
}
#[test]
fn balancing_columns_gives_back_the_header_repeats_it_throws_away() {
// The table runs over both columns of a page and onto a second one.
let d = balanced_header_table(120);
let full = lay(&d);
let shown = header_cells(&full);
assert!(shown >= 3, "the header repeats in each later column: {shown} header rows");
// What one repeat costs: the smallest cap at which a plain two-page table repeats its header.
let mut plain = Table::new(80, 1, 200.0);
plain.rows[0].props.header = true;
let mut p = Document::from_text("");
p.insert_block(StoryRef::Body, &Path::top(1), Block::Table(plain)).unwrap();
let cost = (1..=200).find(|cap| with_header_cap(*cap, || header_cells(&lay(&p))) >= 2).expect("a header repeat costs a few items");
// A budget holding exactly the repeats shown is enough: the balancing tries thrown away don't
// spend it.
let capped = with_header_cap((shown - 1) * cost, || lay(&d));
assert_eq!(header_cells(&capped), shown, "the final layout repeats the header as often");
}
+9 -68
View File
@@ -208,66 +208,13 @@ struct Cut {
byte: usize,
}
/// Most work a chain's story is laid out for, summed over its layouts. Each distinct line length
/// (and text direction) lays the whole story out again and is kept while the chain is split, so
/// a chain of boxes that each differ in width multiplies its story's layout work and memory by
/// its widths. A layout costs the lines and other items it gives, or its story's text at
/// [`STORY_BYTES_PER_WORK`] bytes a unit, whichever is more: text that gives few lines (hidden,
/// deleted, very wide lines, or cut by a box inside) still costs what shaping it does. So the
/// layouts of a chain shape at most about 64 MB of text, plus the one that crosses the cap and
/// the first in each other direction. Within this every box of an ordinary story (even all
/// [`wordcraft_doc::MAX_LINKED_BOXES`]) keeps its exact width; past it, a box whose length isn't
/// among those laid out takes the nearest narrower one laid out in its text direction, so its
/// lines still fit across it (they stop short of its far edge), else the narrowest, whose lines
/// are longer than the box. Only a huge story gets there.
pub(crate) const MAX_FLOW_WORK: usize = 1_000_000;
/// Bytes of story text that cost one unit of [`MAX_FLOW_WORK`]: about a line's worth.
pub(crate) const STORY_BYTES_PER_WORK: usize = 64;
#[cfg(test)]
thread_local! {
/// A smaller cap for tests, so a short story can reach it.
pub(crate) static FLOW_WORK_CAP: std::cell::Cell<usize> = const { std::cell::Cell::new(MAX_FLOW_WORK) };
/// How many times chains' stories have been laid out, for tests.
pub(crate) static FLOW_LAYOUTS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn flow_work_cap() -> usize {
#[cfg(test)]
return FLOW_WORK_CAP.with(|c| c.get());
#[cfg(not(test))]
MAX_FLOW_WORK
}
/// What laying `items` out cost: its lines and other items (a clipped group's members, nested
/// ones too, each count, its lines each).
pub(crate) fn flow_work(items: &[Placed]) -> usize {
let cap = flow_work_cap();
items.iter().map(|it| super::placed_cost_by(it, cap, line_count)).fold(0, usize::saturating_add)
}
/// One for an item, or its lines for a run of lines.
fn line_count(it: &Placed) -> usize {
match it {
Placed::Lines { l0, l1, .. } => l1.saturating_sub(*l0).max(1),
_ => 1,
}
}
/// The work laying `blocks` out costs whatever it gives (see [`MAX_FLOW_WORK`]): its text's
/// bytes, tables' cells too, over [`STORY_BYTES_PER_WORK`].
fn story_work(blocks: &Blocks) -> usize {
let mut bytes = 0usize;
let mut stack: Vec<&Arc<Block>> = blocks.iter().collect();
while let Some(b) = stack.pop() {
match &**b {
Block::Para(p) => bytes = bytes.saturating_add(p.text.len()),
Block::Table(t) => stack.extend(t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks)),
}
}
bytes / STORY_BYTES_PER_WORK
}
/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole
/// story out again and is kept while the chain is split, so a chain of boxes that each differ in
/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't
/// among them takes the nearest narrower one laid out in its text direction, so its lines still
/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer
/// than the box. Far above a real chain's few box sizes.
pub(crate) const MAX_FLOW_WIDTHS: usize = 16;
/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart
/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size.
@@ -290,9 +237,6 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
let snap = (ctx.counters.clone(), ctx.eq_count);
let mut after = None;
let mut layouts: Vec<((i32, TextVert), Vec<Placed>)> = Vec::new();
// Work done by the layouts so far (see `MAX_FLOW_WORK`).
let mut work = 0usize;
let floor = story_work(&blocks);
let mut out = Vec::with_capacity(chain.len());
// Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown.
let mut start: Option<Option<Cut>> = Some(None);
@@ -305,20 +249,17 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec<Vec<Placed>> {
let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0));
let (len, across) = frame(&body, w, h);
let key = flow_key(len, body.vert);
// Past the work cap the box takes the nearest layout in its direction: the widest line length
// Past the cap the box takes the nearest layout in its direction: the widest line length
// that fits it, else the narrowest. The first box in a direction is laid out (one more
// layout for each of the few directions at most).
let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0)));
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if work >= flow_work_cap() { near() } else { None });
let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None });
let items = match kept {
Some((_, items)) => items.clone(),
None => {
(ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
let items = lay(ctx, head, &blocks, len, body.vert, depth);
after.get_or_insert_with(|| (ctx.counters.clone(), ctx.eq_count));
#[cfg(test)]
FLOW_LAYOUTS.with(|c| c.set(c.get().saturating_add(1)));
work = work.saturating_add(flow_work(&items).max(floor));
layouts.push((key, items.clone()));
items
}
-1
View File
@@ -25,7 +25,6 @@ wordcraft-render = { workspace = true }
wordcraft-engine = { workspace = true }
wordcraft-zotero = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+9 -2
View File
@@ -10,7 +10,7 @@
//! document unless the first line is `{"empty": true}`; `{"empty": true, "startScreen": true}`
//! starts the way the desktop app does with nothing to open (File › Home, #766). A line
//! `{"demoPacks": true}` lists a few sample Language packs (installs only record themselves), and
//! `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
//! (with `{"network": true}` on a line, downloading is already on) `{"demoPacks": "store"}` uses a real font store, as the desktop app does, in a new temporary
//! folder rather than the person's, or in `"storeDir"` given on the same line (with
//! `CRAFT_LANGUAGE_PACK_INDEX` for a local pack index).
@@ -40,7 +40,14 @@ fn main() {
.unwrap_or_else(|| std::env::temp_dir().join(format!("ui_shot-font-store-{}", std::process::id())));
wordcraft_engine::packs::use_font_store_in(dir, true);
}
Some(_) => wordcraft_engine::packs::set_backend(Some(std::sync::Arc::new(wordcraft_engine::packs::FixedBackend::sample()))),
Some(_) => {
// `"network": true` on a line has downloading already on (to show what follows a click on Add a language…).
let backend = wordcraft_engine::packs::FixedBackend::sample();
if lines.iter().any(|l| l.get("network") == Some(&serde_json::Value::Bool(true))) {
backend.network.store(true, std::sync::atomic::Ordering::Relaxed);
}
wordcraft_engine::packs::set_backend(Some(std::sync::Arc::new(backend)));
}
None => {}
}
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
+50 -208
View File
@@ -250,17 +250,12 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.label(RichText::new(summary).small().weak());
ui.separator();
crate::rtl::island(ui, |ui| {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
ui.set_max_height(360.0);
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
});
} else {
egui::ScrollArea::vertical().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -269,149 +264,40 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
});
}
}
})
});
ui.data_mut(|d| d.insert_temp(id, v));
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
} else {
*w
}
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(tl!(k.label().1)).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(tl!(k.label().0));
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
crate::rtl::grid(ui, egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()), |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", tl!(k.label().1))).strong()).on_hover_text(tl!(k.label().0)).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -421,53 +307,24 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(tl!(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
crate::rtl::island(ui, |ui| {
ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| {
ui.set_max_height(360.0);
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(tl!(title)).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("credits_models").striped(true).num_columns(6), |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(tl!(h)).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
})
});
}
@@ -547,19 +404,4 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
}
@@ -322,7 +322,7 @@ impl LanguageForm {
/// A language's name in the interface language (the tag itself when we don't know it).
pub(super) fn language_name(tag: &str) -> String {
crate::ribbon::display_name(Some(tag))
tl!(&crate::ribbon::language_name(Some(tag))).to_string()
}
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
+1 -1
View File
@@ -1112,7 +1112,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool {
let Dialog::About { tab } = d else {
return false;
};
ui.set_width(820.0);
ui.set_width(660.0);
let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string());
if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) {
*tab = i as u8;
+562 -100
View File
@@ -2,7 +2,7 @@
//! bar over the page and About › Language data. The engine does the work
//! ([`wordcraft_engine::packs`]): in the app installs and fetches of the list run on its thread,
//! whoever asks (these buttons, or `proof.installPack` from MCP and the control channel), so the
//! window keeps drawing. A click of the person's own on Install, Show available languages or
//! window keeps drawing. A click of the person's own on Install, Add a language… or
//! Refresh is their consent: it turns on the font store's downloading (there is no second
//! prompt, R6.3). A click the control channel made never does.
@@ -11,7 +11,7 @@ use std::sync::Arc;
use egui::{RichText, Ui, vec2};
use serde_json::json;
use wordcraft_engine::packs::{self, Pack};
use wordcraft_engine::packs::{self, LanguageRow, Pack, Proofing};
use crate::WordApp;
use crate::theme::{Tokens, semibold};
@@ -84,6 +84,14 @@ pub(crate) struct Runtime {
missing: Option<(MissingKey, f64, Offers)>,
/// [`packs::finished`] when last looked at.
finished: u64,
/// The Your languages rows, and what they were worked out from.
rows: Option<(RowsKey, Vec<LanguageRow>)>,
/// Is the Add a language window open?
picker: bool,
/// Its search text.
search: String,
/// Has its search box been given focus since it opened?
focused: bool,
/// The person clicked the bar's Install before the list was known: the list's failure then
/// shows in the bar (an automatic fetch's stays in Options).
asked: bool,
@@ -160,8 +168,9 @@ pub(crate) const MORE_LANGUAGES: [(&str, &str); 28] = [
/// the Language dialog name it; else the pack's own name; else its locale.
pub(crate) fn language_of(pack_locale: &str, endonym: &str) -> String {
let tag = crate::ribbon::tag_for_pack(pack_locale);
if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) {
return crate::ribbon::display_name(Some(&tag));
let named = crate::ribbon::language_name(Some(&tag));
if !named.eq_ignore_ascii_case(&tag) {
return tl!(&named).to_string();
}
if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() }
}
@@ -207,7 +216,7 @@ fn error_of(app: &WordApp, locale: &str) -> Option<String> {
})
}
/// Downloading on for a click on Install, Show available languages or Refresh: the person's
/// Downloading on for a click on Install, Add a language… or Refresh: the person's
/// consent. A click the control channel made (an agent's `click`) leaves it off and says why.
fn consent(app: &mut WordApp, ctx: &egui::Context, key: &str) -> bool {
let Some(backend) = packs::backend() else { return false };
@@ -238,7 +247,7 @@ fn start_install(app: &mut WordApp, ctx: &egui::Context, pack: &Pack) {
app.packs_rt.missing = None;
}
/// Fetch the list again (on the engine's thread): the person clicked Show available languages
/// Fetch the list again (on the engine's thread): the person clicked Add a language…
/// or Refresh.
fn start_fetch(app: &mut WordApp, ctx: &egui::Context) {
app.packs_rt.errors.remove("");
@@ -434,137 +443,396 @@ fn licence_hover(pack: &Pack) -> String {
lines.join("\n")
}
/// File › Options › Language › Language packs: every available and installed pack with its
/// name, size, licence and source, and Install or Remove.
pub(crate) fn options_section(app: &mut WordApp, ui: &mut Ui) {
// Pack names include Arabic and other scripts; ask for a system Arabic face when none is embedded.
/// What the Your languages list needs of the engine's rows, worked out again when the document,
/// the packs, the dictionaries, the default language or the folder change.
type RowsKey = (MissingKey, Option<String>, Option<String>, Option<String>);
fn your_rows(app: &mut WordApp) -> Vec<LanguageRow> {
let key: RowsKey = (
MissingKey {
document: app.session.document_id(),
rev: app.session.rev(),
generation: wordcraft_engine::proof::dictionary_generation(),
packs: packs::index_generation(),
// The rows don't follow the caret (it is only made bold when drawn).
caret: None,
},
app.session.prefs.default_language.clone(),
app.session.prefs.dictionaries_folder.clone(),
// The default when none is saved: changing the interface language changes it, with no edit.
app.session.editing_lang.clone(),
);
if let Some((k, rows)) = &app.packs_rt.rows
&& *k == key
{
return rows.clone();
}
let rows = packs::your_languages(&app.session);
app.packs_rt.rows = Some((key, rows.clone()));
rows
}
/// The tag Set as default sets for a row: the one the document or default uses, else the one a
/// text in the pack's language is marked with.
fn row_tag(row: &LanguageRow) -> String {
row.tags.first().cloned().or_else(|| row.pack.as_deref().map(crate::ribbon::tag_for_pack)).unwrap_or_default()
}
/// A row's interface name and, beneath it, its own name and code (`Nederlands · nl`).
fn row_names(row: &LanguageRow, index: &[Pack], installed: &[packs::Installed]) -> (String, String) {
let tag = row_tag(row);
let endonym = row
.pack
.as_deref()
.and_then(|l| {
index.iter().find(|p| p.locale == l).map(|p| p.name.clone()).or_else(|| installed.iter().find(|i| i.locale == l).map(|i| i.name.clone()))
})
.unwrap_or_default();
let named = crate::ribbon::language_name(Some(&tag));
let name = if named.eq_ignore_ascii_case(&tag) && !endonym.trim().is_empty() { shown(&endonym, MAX_BAR_NAME) } else { tl!(&named).to_string() };
let endonym = shown(&endonym, MAX_BAR_NAME);
let detail = if endonym.is_empty() || endonym == name { tag } else { format!("{endonym} · {tag}") };
(name, detail)
}
/// Does `query` find a pack: part of its interface name or own name, or the start of its code
/// (dutch, nederlands, nl).
fn matches(query: &str, name: &str, endonym: &str, locale: &str) -> bool {
let q = query.trim().to_lowercase();
q.is_empty() || name.to_lowercase().contains(&q) || endonym.to_lowercase().contains(&q) || locale.to_lowercase().starts_with(&q)
}
/// The packs the picker offers for `query`: not installed, by interface name.
fn picker_packs(index: &[Pack], installed: &[packs::Installed], query: &str) -> Vec<(String, Pack)> {
let mut out: Vec<(String, Pack)> = index
.iter()
.filter(|p| !installed.iter().any(|i| i.locale == p.locale))
.map(|p| (shown(&language_of(&p.locale, &p.name), MAX_BAR_NAME), p.clone()))
.filter(|(name, p)| matches(query, name, &shown(&p.name, MAX_BAR_NAME), &p.locale))
.collect();
out.sort_by_cached_key(|(name, p)| (crate::ribbon::sort_key(name), p.locale.clone()));
out
}
/// Your languages as listed: interface name, own name and code, and the row, by name.
fn your_view(app: &mut WordApp, index: &[Pack], installed: &[packs::Installed]) -> Vec<(String, String, LanguageRow)> {
let mut view: Vec<(String, String, LanguageRow)> = your_rows(app)
.into_iter()
.map(|r| {
let (name, detail) = row_names(&r, index, installed);
(name, detail, r)
})
.collect();
view.sort_by_cached_key(|(name, _, r)| (crate::ribbon::sort_key(name), r.pack.clone()));
view
}
/// File › Options › Language › Your languages: the languages in use and installed, each with
/// where its proofing comes from and what can be done, and Add a language….
pub(crate) fn your_languages_section(app: &mut WordApp, ui: &mut Ui) {
// Language names include Arabic and other scripts; ask for a system Arabic face when none is embedded.
app.want_system_arabic = true;
let t = Tokens::get(ui.ctx());
ui.label(RichText::new(tl!("Language packs")).font(semibold(15.0)));
ui.label(RichText::new(tl!("Your languages")).font(semibold(15.0)));
ui.add_space(2.0);
ui.label(RichText::new(tl!("Spelling dictionaries and hyphenation for more languages. English is built in.")).small().weak());
let Some(backend) = packs::backend() else {
ui.label(
RichText::new(tl!(
"English, the languages of this document and every installed Language pack. Language packs add spelling dictionaries and hyphenation."
))
.small()
.weak(),
);
ui.add_space(4.0);
let backend = packs::backend();
if backend.is_none() {
let why = if cfg!(target_arch = "wasm32") {
tl!("Language packs aren't available in the browser.")
} else {
tl!("Language packs need the font store folder, and there is none on this computer.")
};
ui.label(RichText::new(why).small().weak());
return;
};
}
if let Some(source) = packs::index_override() {
let line = crate::i18n::fmt(tl!("Language packs come from a test source: {source}"), &[("source", &shown(&source, 300))]);
ui.add(egui::Label::new(RichText::new(line).small().color(t.orange)).wrap());
}
let index = packs::available();
let installed = packs::installed();
let network = backend.network_on();
let caret = crate::ribbon::caret_lang_tag(app);
let view = your_view(app, &index, &installed);
let mut install = None;
let mut removal = None;
let mut default = None;
// As wide as the window leaves room for (the rows scroll sideways in a narrow one).
let room = (ui.ctx().content_rect().right() - ui.cursor().min.x - 16.0).clamp(200.0, 760.0);
let narrow = room < 660.0;
egui::ScrollArea::both().id_salt("options_your_languages").max_width(room).max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
crate::rtl::grid(
ui,
egui::Grid::new("options_your_languages_grid").num_columns(if narrow { 2 } else { 3 }).spacing(vec2(16.0, 8.0)).striped(true),
|ui| {
for (name, detail, row) in &view {
let mut actions = |ui: &mut Ui| {
ui.with_layout(egui::Layout::left_to_right(egui::Align::TOP), |ui| {
if !row.default
&& ui
.small_button(tl!("Set as default"))
.on_hover_text(tl!("New blank documents are written in this language"))
.clicked()
{
default = Some(row_tag(row));
}
if row.installed
&& let Some(locale) = &row.pack
&& ui.add_enabled(!busy(), egui::Button::new(tl!("Remove")).small()).clicked()
{
removal = Some(locale.clone());
}
});
};
let here = caret.as_deref().is_some_and(|c| row.is_tag(c));
ui.vertical(|ui| {
ui.set_width(if narrow { 170.0 } else { 250.0 });
let shown_name = shown(name, MAX_BAR_NAME);
let line = if row.default { crate::i18n::fmt(tl!("{language} (default)"), &[("language", &shown_name)]) } else { shown_name };
ui.label(if here { RichText::new(line).font(semibold(14.0)) } else { RichText::new(line) });
ui.label(RichText::new(tl!(detail)).small().weak());
});
let pack = row.pack.as_deref().and_then(|l| index.iter().find(|p| p.locale == l));
let size = size_text(row.size);
ui.vertical(|ui| {
ui.set_width(190.0);
match row.proofing {
Proofing::BuiltIn => {
ui.label(tl!("Built in"));
}
Proofing::Installed => {
ui.label(RichText::new(tl!("Installed")).color(t.green));
}
Proofing::OwnDictionary => {
ui.label(tl!("Your own dictionary"));
}
Proofing::SystemDictionary => {
ui.label(tl!("System dictionary"));
}
Proofing::NotAvailable => {
ui.label(RichText::new(tl!("Not available yet")).weak());
}
Proofing::GrammarBuiltIn => {
ui.label(tl!("Grammar built in"));
}
Proofing::Install => {}
}
if let Some(pack) = pack
&& !row.installed
&& matches!(row.proofing, Proofing::Install | Proofing::GrammarBuiltIn)
{
match progress(&pack.locale) {
Some((done, total)) => {
ui.horizontal(|ui| {
ui.add(egui::ProgressBar::new(done.min(total) as f32 / total as f32).desired_width(90.0).show_percentage());
if ui.small_button(tl!("Cancel")).clicked() {
packs::cancel(&pack.locale);
}
});
}
None => {
let label = if row.proofing == Proofing::Install {
crate::i18n::fmt(tl!("Install ({size})"), &[("size", &size)])
} else {
crate::i18n::fmt(tl!("Install spelling ({size})"), &[("size", &size)])
};
if ui.button(label).on_hover_text(licence_hover(pack)).clicked() {
install = Some(pack.clone());
}
}
}
}
if narrow {
actions(ui);
}
});
if !narrow {
actions(ui);
}
ui.end_row();
let error = row.pack.as_deref().and_then(|l| error_of(app, l));
let unreadable = (matches!(row.proofing, Proofing::OwnDictionary | Proofing::SystemDictionary))
.then(|| wordcraft_engine::proof::dictionary_load_error(wordcraft_engine::proof::ProofLang::from_tag(Some(&row_tag(row)))))
.flatten();
if error.is_some() || unreadable.is_some() {
ui.label("");
ui.vertical(|ui| {
if let Some(e) = error {
ui.add(egui::Label::new(RichText::new(e).small().color(t.red)).wrap());
}
if let Some(reason) = unreadable {
let line = crate::i18n::fmt(tl!("Its dictionary couldn't be read: {reason}"), &[("reason", &shown_error(&reason))]);
ui.add(egui::Label::new(RichText::new(line).small().color(t.red)).wrap());
}
});
if !narrow {
ui.label("");
}
ui.end_row();
}
}
},
);
});
ui.add_space(4.0);
if let Some(e) = error_of(app, "") {
ui.add(egui::Label::new(RichText::new(e).small().color(t.red)).wrap());
}
ui.horizontal(|ui| {
if ui.add_enabled(backend.is_some(), egui::Button::new(tl!("Add a language…"))).clicked() {
open_picker(app, &ui.ctx().clone());
}
if packs::fetching() {
ui.spinner();
ui.label(RichText::new(tl!("Getting the list of languages…")).small());
} else if index.is_empty() && !network {
if ui.button(tl!("Show available languages")).clicked() {
start_fetch(app, &ui.ctx().clone());
}
} else if ui.button(tl!("Refresh")).on_hover_text(tl!("Get the list of languages again")).clicked() {
start_fetch(app, &ui.ctx().clone());
}
});
if !network {
if backend.as_ref().is_some_and(|b| !b.network_on()) {
ui.label(
RichText::new(tl!(
"Downloading is off. Clicking Show available languages, Refresh or Install turns it on: WordCraft then downloads language packs and fonts from GitHub."
"Downloading is off. Clicking Add a language… or Install turns it on: WordCraft then downloads language packs and fonts from GitHub."
))
.small()
.weak(),
);
}
// Installed packs the list no longer has stay removable.
let mut rows: Vec<(Pack, bool)> = index.iter().map(|p| (p.clone(), installed.iter().any(|i| i.locale == p.locale))).collect();
for i in &installed {
if !rows.iter().any(|(p, _)| p.locale == i.locale) {
let pack = Pack {
locale: i.locale.clone(),
name: i.name.clone(),
version: String::new(),
url: String::new(),
source: None,
size: 0,
licence: i.licence.clone(),
class: String::new(),
spelling: false,
hyphenation: false,
};
rows.push((pack, true));
}
}
if rows.is_empty() {
return;
}
ui.add_space(4.0);
let mut install = None;
let mut removal = None;
egui::ScrollArea::vertical().id_salt("options_language_packs").max_height(260.0).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("options_language_packs_grid").num_columns(5).spacing(vec2(14.0, 6.0)).striped(true), |ui| {
for h in ["Language", "Size", "Licence", "Source", ""] {
ui.label(RichText::new(if h.is_empty() { "" } else { tl!(h) }).small().color(t.text_dim));
}
ui.end_row();
for (pack, is_installed) in &rows {
ui.vertical(|ui| {
ui.set_width(180.0);
let language = shown(&language_of(&pack.locale, &pack.name), MAX_BAR_NAME);
let name = shown(&pack.name, MAX_BAR_NAME);
// The pack's own name when it differs (Nederlands for Dutch), and its locale.
let detail = if language == name { pack.locale.clone() } else { format!("{name} · {}", pack.locale) };
ui.label(language);
ui.label(RichText::new(detail).small().weak());
});
ui.label(if pack.size > 0 { size_text(pack.size) } else { String::new() });
ui.vertical(|ui| {
ui.set_width(220.0);
ui.add(egui::Label::new(RichText::new(shown(&pack.licence, 300)).small()).wrap());
});
let source = pack.source_link();
if source.is_empty() {
ui.label("");
} else {
ui.hyperlink_to(tl!("Source"), source).on_hover_text(shown(source, 300));
}
ui.horizontal(|ui| {
if let Some((done, total)) = progress(&pack.locale) {
ui.add(egui::ProgressBar::new(done.min(total) as f32 / total as f32).desired_width(90.0).show_percentage());
if ui.small_button(tl!("Cancel")).clicked() {
packs::cancel(&pack.locale);
}
} else if *is_installed {
ui.label(RichText::new(tl!("Installed")).small().color(t.green));
if ui.add_enabled(!busy(), egui::Button::new(tl!("Remove"))).clicked() {
removal = Some(pack.locale.clone());
}
} else if ui.button(tl!("Install")).clicked() {
install = Some(pack.clone());
}
});
ui.end_row();
if let Some(e) = error_of(app, &pack.locale) {
ui.label("");
ui.add(egui::Label::new(RichText::new(e).small().color(t.red)).wrap());
ui.end_row();
}
}
});
});
if let Some(p) = install {
start_install(app, &ui.ctx().clone(), &p);
}
if let Some(l) = removal {
remove(app, &l);
}
if let Some(tag) = default
&& let Err(e) = app.run("review.language", json!({"lang": tag, "preferredOnly": true}))
{
app.status(e);
}
}
/// The Add a language… click: the person's own click is their consent to download (an agent's
/// never is: it leaves downloading off and says why); then the picker opens, and the list of
/// languages is fetched when downloading was just turned on or there is none yet.
fn open_picker(app: &mut WordApp, ctx: &egui::Context) {
let Some(backend) = packs::backend() else { return };
app.packs_rt.errors.remove("");
let was_on = backend.network_on();
if !consent(app, ctx, "") {
return;
}
if !was_on || packs::available().is_empty() {
start_fetch(app, ctx);
}
app.packs_rt.search.clear();
app.packs_rt.picker = true;
}
/// The Add a language window: search, then the packs not installed, by name, each with its size,
/// licence, source and Install. Stays open while installs run.
pub(crate) fn add_language_window(app: &mut WordApp, ctx: &egui::Context) {
if !app.packs_rt.picker {
return;
}
// Pack names include Arabic and other scripts.
app.want_system_arabic = true;
let t = Tokens::get(ctx);
let mut open = true;
egui::Window::new(tl!("Add a language"))
.open(&mut open)
.collapsible(false)
.auto_sized()
.anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -80.0))
.show(ctx, |ui| {
// Right to left the search row and the list mirror, as the other windows.
crate::rtl::root(ui, |ui| {
let index = packs::available();
let installed = packs::installed();
ui.horizontal(|ui| {
let search_label = ui.label(tl!("Search:"));
let mut search = std::mem::take(&mut app.packs_rt.search);
let r = ui
.add(egui::TextEdit::singleline(&mut search).desired_width(280.0).hint_text(tl!("Dutch, Nederlands or nl")))
.labelled_by(search_label.id);
if !r.has_focus() && app.packs_rt.search.is_empty() && search.is_empty() && !app.packs_rt.focused {
r.request_focus();
}
app.packs_rt.focused = true;
app.packs_rt.search = search;
if packs::fetching() {
ui.spinner();
ui.label(RichText::new(tl!("Getting the list of languages…")).small());
} else if ui.button(tl!("Refresh")).on_hover_text(tl!("Get the list of languages again")).clicked() {
start_fetch(app, &ui.ctx().clone());
}
});
if let Some(e) = error_of(app, "") {
ui.add(egui::Label::new(RichText::new(e).small().color(t.red)).wrap());
}
ui.add_space(4.0);
let list = picker_packs(&index, &installed, &app.packs_rt.search);
if list.is_empty() {
let text = if index.is_empty() { tl!("No languages to show yet.") } else { tl!("No language matches your search.") };
ui.label(RichText::new(text).weak());
}
let mut install = None;
egui::ScrollArea::both().id_salt("add_language_list").max_height(340.0).auto_shrink([true, true]).show(ui, |ui| {
crate::rtl::grid(ui, egui::Grid::new("add_language_grid").num_columns(4).spacing(vec2(14.0, 8.0)).striped(true), |ui| {
for (name, pack) in &list {
ui.vertical(|ui| {
ui.set_width(180.0);
ui.label(name.as_str());
let endonym = shown(&pack.name, MAX_BAR_NAME);
let detail = if endonym == *name { pack.locale.clone() } else { format!("{endonym} · {}", pack.locale) };
ui.label(RichText::new(tl!(&detail)).small().weak());
});
ui.label(if pack.size > 0 { size_text(pack.size) } else { String::new() });
ui.vertical(|ui| {
ui.set_width(150.0);
let licence = shown(&pack.licence, 300);
ui.add(egui::Label::new(RichText::new(licence.as_str()).small()).truncate()).on_hover_text(licence_hover(pack));
let source = pack.source_link();
if !source.is_empty() {
ui.hyperlink_to(tl!("Source"), source).on_hover_text(shown(source, 300));
}
});
ui.horizontal(|ui| match progress(&pack.locale) {
Some((done, total)) => {
ui.add(egui::ProgressBar::new(done.min(total) as f32 / total as f32).desired_width(80.0).show_percentage());
if ui.small_button(tl!("Cancel")).clicked() {
packs::cancel(&pack.locale);
}
}
None => {
if ui.button(tl!("Install")).clicked() {
install = Some(pack.clone());
}
}
});
ui.end_row();
if let Some(e) = error_of(app, &pack.locale) {
ui.label("");
ui.add(egui::Label::new(RichText::new(e).small().color(t.red)).wrap());
ui.label("");
ui.label("");
ui.end_row();
}
}
});
});
if let Some(p) = install {
start_install(app, &ui.ctx().clone(), &p);
}
})
});
if !open {
app.packs_rt.picker = false;
app.packs_rt.focused = false;
}
}
/// Height of About › Language data's list.
@@ -1028,6 +1296,200 @@ mod tests {
assert!(packs::is_installed("pl"));
}
// ---- Options › Language: Your languages, Add a language (CP4.3) ----
struct ResetBackend;
impl Drop for ResetBackend {
fn drop(&mut self) {
packs::set_background(None);
packs::set_backend(None);
}
}
/// An app on File › Options › `pane`, with the sample packs (downloading as `network`), and
/// the context to draw it in.
fn options_app(pane: &str, network: bool) -> (WordApp, egui::Context, std::sync::Arc<FixedBackend>) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas();
ctx.enable_accesskit();
let backend = std::sync::Arc::new(FixedBackend::sample());
backend.network.store(network, Ordering::Relaxed);
packs::set_backend(Some(backend.clone() as std::sync::Arc<dyn Backend>));
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.ui.backstage = true;
a.ui.backstage_page = "options".into();
a.ui.options_pane = pane.into();
(a, ctx, backend)
}
/// The widgets after a few frames: accessibility updates carry only what changed, so each
/// frame's nodes are merged (the latest of a widget wins).
fn settled(a: &mut WordApp, ctx: &egui::Context) -> egui::accesskit::TreeUpdate {
// A command (a document edit, an install) can close the Backstage: Options stays open.
a.ui.backstage = true;
a.ui.backstage_page = "options".into();
let mut merged: std::collections::BTreeMap<u64, (egui::accesskit::NodeId, egui::accesskit::Node)> = Default::default();
let mut last = frame(a, ctx, Vec::new());
for i in 0..5 {
for (id, node) in last.nodes.drain(..) {
merged.insert(id.0, (id, node));
}
if i < 4 {
last = frame(a, ctx, Vec::new());
}
}
last.nodes = merged.into_values().collect();
last
}
/// Installs Dutch: on the engine's thread when another test's window moved installs there
/// (`tick`), so waits for it.
fn install_nl(a: &mut WordApp) {
a.run("proof.installPack", json!({"locale": "nl"})).unwrap();
for _ in 0..500 {
if packs::is_installed("nl") {
return;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
fn labels(tree: &egui::accesskit::TreeUpdate) -> Vec<String> {
tree.nodes.iter().filter_map(|(_, n)| n.label().or_else(|| n.value()).map(str::to_string)).collect()
}
fn has(tree: &egui::accesskit::TreeUpdate, label: &str) -> bool {
labels(tree).iter().any(|l| l == label)
}
/// A fresh install lists English, built in and the default; with Dutch installed, Dutch is a
/// row with Installed and Remove; a document in Thai (no pack) says Not available yet, one in
/// Polish (a pack, grammar built in) offers to install the spelling.
#[test]
fn your_languages_list_the_status_of_each() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = ResetBackend;
let (mut a, ctx, _backend) = options_app("language", true);
let tree = settled(&mut a, &ctx);
assert!(has(&tree, "English (United States) (default)"), "{:?}", labels(&tree));
assert!(has(&tree, "Built in"));
assert!(!labels(&tree).iter().any(|l| l == "Remove" || l == "Installed"));
install_nl(&mut a);
a.run("document.setText", json!({"text": "สวัสดี\nCześć"})).unwrap();
for (para, lang) in [(0, "th-TH"), (1, "pl-PL")] {
a.run("caret.set", json!({"pos": {"block": para, "off": 0}})).unwrap();
a.run("select.paragraph", json!({})).unwrap();
a.run("review.language", json!({"lang": lang})).unwrap();
}
let tree = settled(&mut a, &ctx);
let all = labels(&tree);
assert!(all.iter().any(|l| l == "Dutch"), "{all:?}");
assert!(all.iter().any(|l| l == "Installed") && all.iter().any(|l| l == "Remove"));
assert!(all.iter().any(|l| l == "Thai") && all.iter().any(|l| l == "Not available yet"), "{all:?}");
assert!(all.iter().any(|l| l == "Polish") && all.iter().any(|l| l == "Grammar built in"), "{all:?}");
assert!(all.iter().any(|l| l.starts_with("Install spelling (")), "{all:?}");
// Listed by name: Dutch, English, Polish, Thai.
let view: Vec<String> = super::your_view(&mut a, &packs::available(), &packs::installed()).into_iter().map(|(n, ..)| n).collect();
assert_eq!(view, ["Dutch", "English (United States)", "Polish", "Thai"], "by the name shown");
// The Proofing tab no longer has the Dictionaries section; the folder is on this tab.
assert!(all.iter().any(|l| l == "Your own dictionaries folder") && all.iter().any(|l| l == "Add a language…"));
a.ui.options_pane = "proofing".into();
let tree = settled(&mut a, &ctx);
let all = labels(&tree);
assert!(!all.iter().any(|l| l.starts_with("Dictionaries") || l.contains("dictionaries folder")), "{all:?}");
assert!(all.iter().any(|l| l == "Custom dictionary"));
}
/// The picker finds Dutch by its English name, its own name and its code, leaves out what is
/// installed and sorts by the name shown.
#[test]
fn the_picker_searches_by_name_own_name_and_code() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = ResetBackend;
let index = index_packs();
let none: Vec<packs::Installed> = Vec::new();
for q in ["dutch", "Dutch", "nederlands", "NL", "nl", " nl "] {
let found: Vec<String> = super::picker_packs(&index, &none, q).into_iter().map(|(_, p)| p.locale).collect();
assert!(found.iter().any(|l| l == "nl"), "{q}: {found:?}");
}
assert_eq!(super::picker_packs(&index, &none, "zzzz").len(), 0);
let all = super::picker_packs(&index, &none, "");
assert_eq!(all.len(), index.len(), "regional variants are rows of their own");
assert!(all.windows(2).all(|w| crate::ribbon::sort_key(&w[0].0) <= crate::ribbon::sort_key(&w[1].0)), "sorted by shown name");
let installed = vec![packs::Installed { locale: "nl".into(), name: "Nederlands".into(), licence: String::new(), path: Default::default() }];
assert!(!super::picker_packs(&index, &installed, "dutch").iter().any(|(_, p)| p.locale == "nl"));
}
/// Add a language…: a click the control channel made never turns downloading on (it says
/// why and opens nothing); the person's own click does, and opens the picker.
#[test]
fn a_faked_click_on_add_a_language_never_turns_on_downloading() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = ResetBackend;
let (mut a, ctx, backend) = options_app("language", false);
let tree = settled(&mut a, &ctx);
let add = spot(&tree, "Add a language").expect("the button");
as_agent(&mut a, &ctx, "ui.click", json!({"x": add.0, "y": add.1}));
assert!(!backend.network.load(Ordering::Relaxed), "a faked click leaves downloading off");
assert!(!a.packs_rt.picker, "and opens nothing");
assert!(a.packs_rt.errors.get("").is_some_and(|e| e.contains("Only you")), "{:?}", a.packs_rt.errors);
// The reason is shown above the button, which moves down.
let add = spot(&settled(&mut a, &ctx), "Add a language").expect("the button");
user_click(&mut a, &ctx, add);
assert!(backend.network.load(Ordering::Relaxed), "the person's click is the consent");
assert!(a.packs_rt.picker);
for _ in 0..500 {
if !packs::fetching() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
let tree = settled(&mut a, &ctx);
assert!(has(&tree, "Search:") && has(&tree, "Refresh"), "{:?}", labels(&tree));
assert!(labels(&tree).iter().any(|l| l == "Catalan"), "the packs are listed by name: {:?}", labels(&tree));
}
/// Set as default sets only the saved default proofing language (`preferredOnly`); the document is untouched.
#[test]
fn set_as_default_changes_new_texts_language() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = ResetBackend;
let (mut a, ctx, _backend) = options_app("language", true);
install_nl(&mut a);
let tree = settled(&mut a, &ctx);
// Dutch is listed first and isn't the default: its Set as default is the first one.
let set = spot(&tree, "Set as default").expect("Dutch can be made the default");
assert_eq!(a.session.prefs.default_language, None);
let before = a.session.doc.clone();
user_click(&mut a, &ctx, set);
assert_eq!(a.session.prefs.default_language.as_deref(), Some("nl-NL"));
assert_eq!(a.session.doc, before, "the open document is untouched");
let tree = settled(&mut a, &ctx);
assert!(has(&tree, "Dutch (default)"), "{:?}", labels(&tree));
// English can be set back.
assert!(spot(&tree, "Set as default").is_some());
}
/// Changing the interface language changes the default proofing language when none is saved
/// (the session's editing language), without an edit: the list follows.
#[test]
fn the_list_follows_the_interface_languages_default() {
let _g = BACKEND_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let _reset = ResetBackend;
let (mut a, ctx, _backend) = options_app("language", true);
// A document in use: only the editing language changes, with no revision of its own.
a.run("document.setText", json!({"text": "Hello"})).unwrap();
let tree = settled(&mut a, &ctx);
assert!(has(&tree, "English (United States) (default)"), "{:?}", labels(&tree));
let rev = a.session.rev();
a.session.set_editing_language(Some("pl-PL"));
let tree = settled(&mut a, &ctx);
assert_eq!(a.session.rev(), rev, "no edit");
assert!(has(&tree, "Polish (default)"), "{:?}", labels(&tree));
}
/// A font store that never fetched the list (a fresh install): `index` has nothing.
struct Uncached(FixedBackend);
+12 -68
View File
@@ -337,20 +337,17 @@ fn proofing(app: &mut WordApp, ui: &mut Ui) {
ui.add_space(8.0);
section(ui, "Custom dictionary");
custom_dictionary(app, ui);
ui.add_space(8.0);
section(ui, "Dictionaries");
dictionaries(app, ui);
}
/// Where the spelling dictionaries come from (#1063): a folder searched before the system's
/// usual places, saved with the settings, and the dictionary each language found. An app started
/// from Finder or the Dock doesn't see `WORDCRAFT_DICTIONARIES`, so this is how it finds a
/// dictionary kept in a folder of the user's own.
fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
/// Your own dictionaries folder (#1063): searched before the system's usual places, saved with
/// the settings. An app started from Finder or the Dock doesn't see `WORDCRAFT_DICTIONARIES`, so
/// this is how it finds a dictionary kept in a folder of the user's own. Each language's status
/// ("Your own dictionary") is in Your languages above.
fn dictionaries_folder(app: &mut WordApp, ui: &mut Ui) {
let saved = app.session.prefs.dictionaries_folder.clone().unwrap_or_default();
let id = egui::Id::unique("options_dictionaries_folder");
let mut folder = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| saved.clone());
let folder_label = ui.label(tl!("Dictionaries folder:")).id;
let folder_label = ui.label(tl!("Folder:")).id;
ui.horizontal(|ui| {
let r = ui
.add(egui::TextEdit::singleline(&mut folder).desired_width(320.0).hint_text(tl!("Not set: the system's usual places")))
@@ -372,64 +369,7 @@ fn dictionaries(app: &mut WordApp, ui: &mut Ui) {
}
});
ui.data_mut(|d| d.insert_temp(id, folder));
note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before the system's.");
// The dictionary each language found, looked up again after the folder changes.
let found_id = egui::Id::unique("options_dictionaries_found");
let generation = wordcraft_engine::proof::dictionary_generation();
let cached = ui.data(|d| d.get_temp::<(u64, Vec<(String, Option<String>, Option<String>)>)>(found_id)).filter(|(g, _)| *g == generation);
let found = match cached {
Some((_, f)) => f,
None => {
let f: Vec<(String, Option<String>, Option<String>)> = wordcraft_engine::proof::installed_dictionaries()
.into_iter()
.map(|(lang, dic)| {
let error = wordcraft_engine::proof::dictionary_load_error(lang);
(language_name(lang).to_string(), dic.map(|d| d.to_string_lossy().into_owned()), error)
})
.collect();
ui.data_mut(|d| d.insert_temp(found_id, (generation, f.clone())));
f
}
};
for (language, file, error) in found {
let line = match (file, error) {
(Some(f), Some(e)) => crate::i18n::fmt(
tl!("{language}: {file} (unreadable: {reason})"),
&[("language", &language), ("file", &crate::i18n::isolate_path(&f)), ("reason", &e)],
),
(Some(f), None) => crate::i18n::fmt(tl!("{language}: {file}"), &[("language", &language), ("file", &crate::i18n::isolate_path(&f))]),
(None, _) => crate::i18n::fmt(tl!("{language}: no dictionary found"), &[("language", &language)]),
};
ui.add(egui::Label::new(RichText::new(line).small()).truncate());
}
}
/// The interface name of a language with a Hunspell dictionary.
fn language_name(lang: wordcraft_engine::proof::ProofLang) -> &'static str {
use wordcraft_engine::proof::ProofLang;
match lang {
ProofLang::Pl => tl!("Polish"),
ProofLang::Pt => tl!("Portuguese (Brazil)"),
ProofLang::It => tl!("Italian"),
ProofLang::Hr => tl!("Croatian"),
ProofLang::Tr => tl!("Turkish"),
ProofLang::Ru => tl!("Russian"),
ProofLang::Cs => tl!("Czech"),
ProofLang::Id => tl!("Indonesian"),
ProofLang::Es => tl!("Spanish (Spain)"),
ProofLang::EsMx => tl!("Spanish (Mexico)"),
ProofLang::Es419 => tl!("Spanish (Latin America)"),
ProofLang::EsUy => tl!("Spanish (Uruguay)"),
// English uses its bundled word list; Bulgarian and German have no dictionary yet.
ProofLang::En
| ProofLang::EnGb
| ProofLang::EnCa
| ProofLang::EnAu
| ProofLang::Bg
| ProofLang::De
| ProofLang::Pack(_)
| ProofLang::Other => "",
}
note(ui, "Hunspell .aff and .dic files here (pl_PL, pt_BR, it_IT, hr_HR, tr_TR) are used before Language packs and the system's.");
}
/// The custom dictionary: the words Add to Dictionary kept, each with a Remove button, and a
@@ -519,7 +459,11 @@ fn language(app: &mut WordApp, ui: &mut Ui) {
};
ui.label(RichText::new(text).small().weak());
ui.add_space(8.0);
crate::language_packs::options_section(app, ui);
crate::language_packs::your_languages_section(app, ui);
ui.add_space(8.0);
section(ui, "Your own dictionaries folder");
dictionaries_folder(app, ui);
crate::language_packs::add_language_window(app, &ui.ctx().clone());
}
/// Draws Accessibility options: the animation setting and Read Aloud speed and skip-citations.
+5 -3
View File
@@ -259,9 +259,11 @@ fn options_panes_open_and_change_settings() {
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("autoCorrect"));
h.state_mut().dialog = None;
h.run_steps(2);
// #1063: the dictionaries folder and the dictionary each language found.
assert!(h.query_by_label("Dictionaries folder:").is_some());
assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line");
// #1063: the dictionaries folder moved to Language (CP4.3); Proofing has no Dictionaries section.
assert!(h.query_by_label("Dictionaries folder:").is_none() && h.query_by_label("Folder:").is_none());
h.get_by_label("Language").click();
h.run_steps(4);
assert!(h.query_by_label("Folder:").is_some(), "Your own dictionaries folder");
h.get_by_label("Advanced").click();
h.run_steps(4);
h.get_by_label("Typing replaces selected text").click();
+2 -3
View File
@@ -52,12 +52,11 @@ pub(crate) use language::all_languages;
use language::language_checks;
#[cfg(test)]
pub(crate) use language::short_languages;
#[cfg(test)]
pub(crate) use language::sort_key;
pub(crate) use language::{
caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu,
caret_lang_tag, caret_language, caret_language_proofed, caret_missing_dictionary, caret_no_proof, caret_unreadable_dictionary, language_menu,
};
pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack};
pub(crate) use language::{language_list, language_name, same_language, tag_for_pack};
use layout::layout;
#[cfg(test)]
use layout::{columns_items, margins_items, orientation_items};
+60 -178
View File
@@ -31,11 +31,18 @@ fn known() -> impl Iterator<Item = &'static (&'static str, &'static str)> {
/// locale in BCP 47 case (`de-at` → `de-AT`). [`wordcraft_engine::packs::pack_for_tag`] finds the
/// same pack again from it, so text marked in a pack's language is proofed by that pack.
pub(crate) fn tag_for_pack(locale: &str) -> String {
let wanted = locale.replace('_', "-");
let exact = known().find(|(t, _)| t.eq_ignore_ascii_case(&wanted));
let exact = known().find(|(t, _)| t.eq_ignore_ascii_case(locale));
let prefixed = || {
known().find(|(t, _)| {
t.len() > locale.len()
&& t.get(..locale.len()).is_some_and(|p| p.eq_ignore_ascii_case(locale))
&& t.as_bytes().get(locale.len()) == Some(&b'-')
})
};
// A variant WordCraft names with its region (`ca-valencia` is `ca-ES-valencia`).
let regioned = || known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(&wanted));
if let Some((t, _)) = exact.or_else(|| named_starting(&wanted)).or_else(regioned) {
let regioned =
|| known().find(|(t, _)| t.contains('-') && without_region(&t.to_ascii_lowercase()) == locale.to_ascii_lowercase().replace('_', "-"));
if let Some((t, _)) = exact.or_else(prefixed).or_else(regioned) {
return (*t).to_string();
}
let parts: Vec<String> = locale
@@ -43,7 +50,7 @@ pub(crate) fn tag_for_pack(locale: &str) -> String {
.enumerate()
.map(|(i, p)| {
let alpha = p.chars().all(|c| c.is_ascii_alphabetic());
if i > 0 && wordcraft_engine::packs::is_region(p) {
if i > 0 && alpha && p.len() == 2 {
p.to_ascii_uppercase()
} else if i > 0 && alpha && p.len() == 4 {
let lower = p.to_ascii_lowercase();
@@ -57,16 +64,6 @@ pub(crate) fn tag_for_pack(locale: &str) -> String {
parts.join("-")
}
/// The first language WordCraft names whose tag starts with `prefix` and a `-` (`bg` is
/// `bg-BG`, `sr-Latn` is `sr-Latn-RS`).
fn named_starting(prefix: &str) -> Option<&'static (&'static str, &'static str)> {
known().find(|(t, _)| {
t.len() > prefix.len()
&& t.get(..prefix.len()).is_some_and(|p| p.eq_ignore_ascii_case(prefix))
&& t.as_bytes().get(prefix.len()) == Some(&b'-')
})
}
/// A pack language's name for the lists, in English: the name WordCraft has for its tag, else the
/// pack's own name (as shown, without format characters), else the tag.
fn pack_language_name(tag: &str, endonym: &str) -> String {
@@ -96,7 +93,7 @@ pub(crate) fn short_languages() -> Vec<(String, String)> {
more.push((tag, name));
}
}
more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n)));
more.sort_by_cached_key(|(_, n)| sort_key(tl!(n)));
out.extend(more);
out
}
@@ -113,7 +110,7 @@ pub(crate) fn all_languages() -> Vec<(String, String)> {
});
for (tag, name) in builtin.chain(packs) {
if !out.iter().any(|(t, _)| t.eq_ignore_ascii_case(&tag)) {
out.push((tag.clone(), display_of(&tag, &name)));
out.push((tag, tl!(&name).to_string()));
}
}
out.sort_by_cached_key(|(_, n)| sort_key(n));
@@ -126,7 +123,7 @@ pub(crate) fn language_list(current: &str) -> Vec<(String, String)> {
let mut list = all_languages();
// A tag the list has under another spelling (`nl` for `nl-NL`) is that row, not another.
if !current.is_empty() && !list.iter().any(|(t, _)| same_language(current, t)) {
list.push((current.to_string(), display_name(Some(current))));
list.push((current.to_string(), tl!(&language_name(Some(current))).to_string()));
list.sort_by_cached_key(|(_, n)| sort_key(n));
}
list
@@ -170,58 +167,21 @@ pub(crate) fn sort_key(name: &str) -> (String, String) {
(folded, lower)
}
/// A language's name in English, as the lists build it: a whole name (`Polish`, `Serbian (Latin)`)
/// or a language with a region the lists have no entry for (`Swedish` and `Finland`).
enum NameParts {
Whole(String),
Regioned { language: String, region: String },
}
/// The English name of a proofing language tag, as the status bar shows it (no tag: WordCraft's
/// default, US English): a listed language by its name; another region of one by the language
/// and the region ("French (Canada)"); a bare language subtag by its usual variant; else the tag.
pub(crate) fn language_name(tag: Option<&str>) -> String {
match name_parts(tag) {
NameParts::Whole(n) => n,
NameParts::Regioned { language, region } => format!("{language} ({region})"),
}
}
/// [`language_name`] in the interface language: a whole name is looked up as it is, a language
/// with a region is the translated `{language} ({region})` of the translated language and region
/// (a name built at run time has no catalog row of its own). English is unchanged.
pub(crate) fn display_name(tag: Option<&str>) -> String {
display_name_with(tag, &|s| tl!(s).to_string())
}
/// [`display_name`] with the lookup given (`translate`: English in, interface language out).
pub(super) fn display_name_with(tag: Option<&str>, translate: &dyn Fn(&str) -> String) -> String {
match name_parts(tag) {
NameParts::Whole(n) => translate(&n),
NameParts::Regioned { language, region } => {
crate::i18n::fmt(&translate("{language} ({region})"), &[("language", &translate(&language)), ("region", &translate(&region))])
}
}
}
/// The name a list shows for `tag` whose English name is `name`: [`display_name`] for the name
/// WordCraft has for the tag, else `name` as it is (a pack's own name for a language WordCraft
/// has none for).
pub(crate) fn display_of(tag: &str, name: &str) -> String {
if name == language_name(Some(tag)) { display_name(Some(tag)) } else { name.to_string() }
}
fn name_parts(tag: Option<&str>) -> NameParts {
let tag = tag.unwrap_or("en-US");
let named = |t: &str| known().find(|(l, _)| l.eq_ignore_ascii_case(t)).map(|(_, n)| *n);
// Any language the Language dialog offers (French, German…) by its name, not its tag (#860).
if let Some(name) = named(tag) {
return NameParts::Whole(name.to_string());
return name.to_string();
}
let mut parts = tag.split(['-', '_']);
let primary = parts.next().unwrap_or("");
// The region: the first region subtag after the language (and script).
let region = parts.find(|p| wordcraft_engine::packs::is_region(p));
// The region: the first two-letter or three-digit subtag after the language (and script).
let region =
parts.find(|p| (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())));
match region {
// A region the list doesn't have: the language's name without its own region or variant.
Some(r) => {
@@ -229,12 +189,12 @@ fn name_parts(tag: Option<&str>) -> NameParts {
.find(|(l, _)| l.split('-').next().is_some_and(|p| p.eq_ignore_ascii_case(primary)))
.map(|(_, n)| n.split(" (").next().unwrap_or(n));
match language {
Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) },
None => NameParts::Whole(tag.to_string()),
Some(language) => format!("{language} ({})", region_name(r)),
None => tag.to_string(),
}
}
// A bare language subtag ("pt", "pl"): the variant WordCraft proofs.
None => NameParts::Whole(match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
wordcraft_engine::proof::ProofLang::Pl => "Polish".into(),
wordcraft_engine::proof::ProofLang::Hr => "Croatian".into(),
wordcraft_engine::proof::ProofLang::Pt => "Portuguese (Brazil)".into(),
@@ -248,10 +208,15 @@ fn name_parts(tag: Option<&str>) -> NameParts {
wordcraft_engine::proof::ProofLang::En if tag.eq_ignore_ascii_case("en") => "English (United States)".into(),
// A language (and script) without its region: the named tag it starts (`bg` is
// Bulgarian, `sr-Latn` Serbian (Latin)).
_ => named_starting(tag)
.or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag)))
_ => known()
.find(|(t, _)| {
t.len() > tag.len()
&& t.get(..tag.len()).is_some_and(|p| p.eq_ignore_ascii_case(tag))
&& t.as_bytes().get(tag.len()) == Some(&b'-')
})
.or_else(|| known().find(|(t, _)| t.contains('-') && without_region(&t.to_ascii_lowercase()).eq_ignore_ascii_case(tag)))
.map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()),
}),
},
}
}
@@ -333,7 +298,7 @@ fn region_name(code: &str) -> String {
/// The language at the caret (resolved through the styles), translated, for the status bar.
pub(crate) fn caret_language(app: &WordApp) -> String {
display_name(caret_lang_tag(app).as_deref())
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
}
/// The formatting at the caret, resolved through the styles and the document default.
@@ -344,7 +309,7 @@ fn caret_char(app: &WordApp) -> wordcraft_doc::resolve::ResolvedChar {
}
/// The language tag at the caret, resolved through the styles and the document default.
fn caret_lang_tag(app: &WordApp) -> Option<String> {
pub(crate) fn caret_lang_tag(app: &WordApp) -> Option<String> {
caret_char(app).lang
}
@@ -387,11 +352,11 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
let no_proof = st.get("noProof").and_then(Value::as_bool).unwrap_or(false);
let entries = short_languages();
let checks = language_checks(&current, &entries);
if checks.extra.is_some() {
mi_check(ui, app, &display_name(Some(&current)), true, "review.language", json!({"lang": current}));
if let Some(name) = checks.extra {
mi_check(ui, app, &name, true, "review.language", json!({"lang": current}));
}
for ((tag, name), on) in entries.iter().zip(checks.listed) {
mi_check(ui, app, &display_of(tag, name), on, "review.language", json!({"lang": tag}));
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
}
ui.separator();
mi_check(ui, app, "Do Not Check Spelling or Grammar", no_proof, "review.language", json!({"noProof": !no_proof}));
@@ -402,132 +367,36 @@ pub(crate) fn language_menu(ui: &mut Ui, app: &mut WordApp) {
/// The Language menu's check marks for the `current` tag: which `entries` (tag, English name) are
/// checked, and the name of the current language when it is none of them, shown checked above.
/// An entry is checked for its own tag (any case), or for a tag that is the same language
/// ([`same_language`]: `pl` for Polish, `ces` for Czech, `PT-br-x-test` for Portuguese (Brazil)),
/// so no language shows twice; English (New Zealand) doesn't check English (United Kingdom).
/// An entry is checked for its own tag (any case), or for a tag WordCraft proofs the same way and
/// names alike (`pl` for Polish, `ces` for Czech, `PT-br-x-test` for Portuguese (Brazil)), so no
/// name shows twice; English (New Zealand) doesn't check English (United Kingdom).
pub(super) struct LanguageChecks {
pub(super) extra: Option<String>,
pub(super) listed: Vec<bool>,
}
pub(super) fn language_checks(current: &str, entries: &[(String, String)]) -> LanguageChecks {
let on: Vec<bool> = entries.iter().map(|(t, _)| same_language(current, t)).collect();
let extra = (!current.is_empty() && !on.contains(&true)).then(|| language_name(Some(current)));
LanguageChecks { extra, listed: on }
let name = (!current.is_empty()).then(|| language_name(Some(current)));
let on: Vec<bool> =
entries.iter().map(|(t, n)| t.eq_ignore_ascii_case(current) || (name.as_deref() == Some(n.as_str()) && same_proofing(current, t))).collect();
LanguageChecks { extra: name.filter(|_| !on.contains(&true)), listed: on }
}
/// A language tag's parts, lowercase: what [`same_language`] compares instead of names.
#[derive(Default)]
struct TagParts {
language: String,
/// Four letters (`latn`).
script: Option<String>,
/// [`wordcraft_engine::packs::is_region`].
region: Option<String>,
/// Five to eight letters or digits, or four starting with a digit (`valencia`).
variants: Vec<String>,
}
/// A tag's parts. An extension or private use (`-x-test`) ends the tag; a tag is a handful of short
/// subtags, so only the start of a longer one is read.
fn tag_parts(tag: &str) -> TagParts {
let lower = tag.trim().replace('_', "-").to_ascii_lowercase();
let mut subtags = lower.split('-').take(8);
let mut out = TagParts { language: subtags.next().unwrap_or("").to_string(), ..Default::default() };
for p in subtags {
let alnum = p.bytes().all(|b| b.is_ascii_alphanumeric());
if p.len() < 2 || !alnum {
break;
}
if out.script.is_none() && out.region.is_none() && p.len() == 4 && p.bytes().all(|b| b.is_ascii_alphabetic()) {
out.script = Some(p.to_string());
} else if out.region.is_none() && wordcraft_engine::packs::is_region(p) {
out.region = Some(p.to_string());
} else if (5..=8).contains(&p.len()) || (p.len() == 4 && p.bytes().next().is_some_and(|b| b.is_ascii_digit())) {
out.variants.push(p.to_string());
}
}
out
}
/// A tag's parts, with the region (and script) the lists name it with when it has none: `nl` is
/// `nl-NL`, `sr-latn` is `sr-Latn-RS`, `pt` is `pt-BR`.
fn listed_parts(tag: &str) -> TagParts {
let mut parts = tag_parts(tag);
if parts.region.is_none() {
let named = tag_parts(&tag_for_pack(tag));
parts.region = named.region;
if parts.script.is_none() {
parts.script = named.script;
}
}
parts
}
/// Are `a` and `b` the same language as the lists show it: the same tag (any case), or tags that
/// agree in script, region and variant (one lacking a script or region takes the one the lists name
/// it with) and that WordCraft proofs alike ([`same_proofing`]): `nl` is the `nl-NL` row, `pl` the
/// `pl-PL` one, `ces` the `cs-CZ` one. Tags are compared, never the names the lists show for them.
/// Are `a` and `b` the same language as the lists show it: the same tag (any case), or named
/// alike and proofed alike ([`same_proofing`]): `nl` is the `nl-NL` row, `pl` the `pl-PL` one.
pub(crate) fn same_language(a: &str, b: &str) -> bool {
if a.eq_ignore_ascii_case(b) {
return true;
}
if a.trim().is_empty() || b.trim().is_empty() {
return false;
}
let (pa, pb) = (listed_parts(a), listed_parts(b));
let agree = |x: &Option<String>, y: &Option<String>| x.is_none() || y.is_none() || x == y;
// A language the lists name no region for (`xx`, `kl`) is not its regioned tag (`xx-AA`): they
// stay separate rows. Rules WordCraft has built in (`ces` for `cs-CZ`) match without one.
use wordcraft_engine::proof::ProofLang;
if pa.region.is_none() != pb.region.is_none() && matches!(ProofLang::from_tag(Some(a)), ProofLang::Pack(_) | ProofLang::Other) {
return false;
}
agree(&pa.script, &pb.script) && agree(&pa.region, &pb.region) && variants_agree(&pa.variants, &pb.variants) && same_proofing(a, b)
}
/// Do two tags' variants (`valencia`, `1996`) agree: the same ones, or one tag has none and the
/// other's are no variant a pack or the lists name (`de-DE-1996` is the `de-DE` row, but
/// `ca-ES-valencia` is not the `ca` one).
fn variants_agree(a: &[String], b: &[String]) -> bool {
let (mut a, mut b) = (a.to_vec(), b.to_vec());
a.sort();
b.sort();
if a == b {
return true;
}
let named = |v: &String| {
let in_tag = |t: &str| t.split(['-', '_']).skip(1).any(|p| p.eq_ignore_ascii_case(v));
known().any(|(t, _)| in_tag(t))
|| wordcraft_engine::packs::available().iter().any(|p| in_tag(&p.locale))
|| wordcraft_engine::packs::installed().iter().any(|p| in_tag(&p.locale))
};
let extra = if a.is_empty() {
&b
} else if b.is_empty() {
&a
} else {
return false;
};
!extra.iter().any(named)
a.eq_ignore_ascii_case(b) || (!a.is_empty() && language_name(Some(a)) == language_name(Some(b)) && same_proofing(a, b))
}
/// Does WordCraft proof `a` and `b` the same way: the same built-in language, or two Language
/// pack languages read from the same pack ([`wordcraft_engine::packs::pack_for_tag`]: `nl` and
/// `nl-NL` are both the `nl` pack's, though their pack locales differ); with neither pack known,
/// the same language and script whatever the region. Languages WordCraft has no rules or pack for
/// are the same only when their language subtags are.
/// the same language and script whatever the region.
fn same_proofing(a: &str, b: &str) -> bool {
use wordcraft_engine::packs;
use wordcraft_engine::proof::ProofLang;
let (la, lb) = (ProofLang::from_tag(Some(a)), ProofLang::from_tag(Some(b)));
if la == lb {
// Languages without rules or a pack are the same only as the same language, script and
// region (`xx` is not `xx-AA`).
if la == ProofLang::Other {
let (pa, pb) = (tag_parts(a), tag_parts(b));
return pa.language == pb.language && pa.script == pb.script && pa.region == pb.region;
}
return true;
}
let (Some(pa), Some(pb)) = (la.pack_locale(), lb.pack_locale()) else { return false };
@@ -550,11 +419,24 @@ fn same_proofing(a: &str, b: &str) -> bool {
}
match (packs::pack_for_tag(a, &known), packs::pack_for_tag(b, &known)) {
(Some(x), Some(y)) => x.locale == y.locale,
(None, None) => packs::without_region(pa.as_str()).eq_ignore_ascii_case(&packs::without_region(pb.as_str())),
(None, None) => without_region(pa.as_str()) == without_region(pb.as_str()),
_ => false,
}
}
/// A lowercase locale without its region subtag (`nl-nl` → `nl`, `sr-latn-rs` → `sr-latn`).
fn without_region(locale: &str) -> String {
locale
.split('-')
.enumerate()
.filter(|(i, p)| {
*i == 0 || !((p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic())) || (p.len() == 3 && p.bytes().all(|b| b.is_ascii_digit())))
})
.map(|(_, p)| p)
.collect::<Vec<&str>>()
.join("-")
}
/// A menu item with a check mark when `checked` (the current choice of several).
pub(super) fn mi_check(ui: &mut Ui, app: &mut WordApp, label: &str, checked: bool, id: &str, params: Value) {
// A vector mark needs no font glyph; the empty atom keeps labels aligned.
-52
View File
@@ -282,58 +282,6 @@ fn language_menu_checks_by_tag_or_proofing_language() {
assert_eq!((c.extra, c.listed.contains(&true)), (None, false));
}
/// CP4.3 review: rows are matched by tag, never by the name shown. Serbian in Latin and in
/// Cyrillic letters of Bosnia show under the same name but are two languages, so neither checks
/// the other's row; a name that is translated or different doesn't matter to the match.
#[test]
fn rows_are_matched_by_tag_not_by_display_name() {
use super::language::same_language;
assert_eq!(language_name(Some("sr-Latn-BA")), language_name(Some("sr-Cyrl-BA")), "same display name");
assert!(!same_language("sr-Latn-BA", "sr-Cyrl-BA"));
let entries = vec![("sr-Latn-BA".to_string(), "Serbian".to_string()), ("sr-Cyrl-BA".to_string(), "Serbian".to_string())];
assert_eq!(language_checks("sr-Cyrl-BA", &entries).listed, [false, true]);
// A variant on an otherwise equal tag is the row without it, unless a pack or the lists name
// the variant (Valencian is not Catalan); a language WordCraft has nothing for keeps its regions apart.
assert!(same_language("de-DE-1996", "de-DE") && same_language("de-DE", "de-DE-1996"));
assert!(!same_language("ca-ES-valencia", "ca-ES"));
assert!(!same_language("xx", "xx-AA"), "xx vs xx-AA");
assert!(same_language("xx-AA", "XX_aa"), "xx-AA vs XX_aa");
assert_eq!(language_checks("de-DE-1996", &[("de-DE".to_string(), "German (Germany)".to_string())]).listed, [true]);
// Two unknown languages never merge, though neither has a name but its tag.
assert!(!same_language("xx", "yy") && !same_language("xx-AA", "yy-AA"));
// The row's name can be anything (translated): the tag decides.
let renamed = vec![("pl-PL".to_string(), "Polski".to_string())];
assert_eq!(language_checks("pl", &renamed).listed, [true]);
assert_eq!(language_checks("PL_pl", &renamed).listed, [true]);
assert_eq!(language_checks("pt", &[("pt-PT".to_string(), "Portuguese (Brazil)".to_string())]).listed, [false]);
}
/// CP4.3 review: a name built from a language and a region (`Swedish (Finland)`) has no catalog row
/// of its own, so the language, the region and the `{language} ({region})` template are looked up
/// separately and joined. English stays as `language_name` builds it.
#[test]
fn a_language_and_region_name_is_translated_in_parts() {
use super::language::display_name_with;
let fake = |s: &str| -> String {
match s {
"Swedish" => "X".into(),
"Finland" => "Y".into(),
"{language} ({region})" => "{language} \u{2013} {region}".into(),
other => other.to_string(),
}
};
assert_eq!(display_name_with(Some("sv-FI"), &fake), "X \u{2013} Y");
// A whole name is looked up as it is; the template isn't used for it.
assert_eq!(display_name_with(Some("sv-SE"), &fake), "X");
// Without a catalogue (English) the name is byte-identical to the English name.
for tag in ["sv-FI", "es-CU", "fr-CA", "de-CH", "pt-AO", "en-SG", "sr-Latn-BA", "pl-PL", "nl", "xx-YY", "xx", "ca-valencia"] {
assert_eq!(super::language::display_name(Some(tag)), language_name(Some(tag)), "{tag}");
}
assert_eq!(language_name(Some("sv-FI")), "Swedish (Finland)");
// Matching never uses a translated name: Finnish Swedish and Swedish of Sweden stay two rows.
assert!(!super::language::same_language("sv-FI", "sv-SE"));
}
/// #860 review: a tag the language list doesn't have shows by language and region, not as
/// the tag; Portuguese (Angola) isn't Brazil's, and no name shows twice in the menu.
#[test]
+2 -2
View File
@@ -84,8 +84,8 @@ requests are never flipped.
Language packs (see [MCP](mcp.md#language-packs)) are commands like any other: `proof.packs`,
`proof.installPack {"locale": "nl"}` and `proof.removePack {"locale": "nl"}`. Through the control
channel they act on the running app's packs, and install only while its downloading setting is on;
they never turn it on, and neither does a `ui.click` or `ui.key` on the app's Install, Show
available languages or Refresh buttons. An install or a refresh runs in the background
they never turn it on, and neither does a `ui.click` or `ui.key` on the app's Install, Add a
language… or Refresh buttons. An install or a refresh runs in the background
(`"started": true`); `proof.packs` reports its progress, then the result.
`ui.dialog {"name": "languageData"}` opens About › Language data.
+3 -4
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -1,6 +1,6 @@
# Localization parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar; craft-i18n.rev at 9d6d54d: region names and the Language (Region) template in 35 locales, craft-i18n#145); previously trivial (craft-i18n.rev at cde4900: rows for Options › Language, the missing-pack bar and 28 Language pack language names in 35 locales, craft-i18n#136); previously trivial (craft-proof pin: French elision suggestions first, ca-ES-valencia reads the ca-valencia pack); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (Options › Language redesign: Your languages, Add a language…, Set as default; dictionaries folder moved from Proofing); previously trivial (Language lists: every installed and available Language pack's language can be chosen in Set Proofing Language, installed ones in Review › Language and the status bar); previously minor (Arabic `ar` interface: craft-i18n catalog, mirrored shell, font fallback, #1195; 28 languages); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-1 is published and pinned, 61 packs; the language requests it covers); previously minor (Language packs: languages beyond English install their spelling dictionary and hyphenation as a pack from File › Options › Language or the missing-pack bar; the default build embeds English Language data only) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
Interface languages, script support and proofing per language. How translation works (adding a
language, the embedded catalogs): `crates/ui-egui/src/i18n.rs`. The catalogs, their format and
@@ -44,7 +44,8 @@ native-speaker review (human).
- Pending PRs: Norwegian Bokmål (#208) and Nynorsk (#210), Hebrew with RTL (#20). Arabic UI landed (#1195).
- **Proofing: English, Polish, Portuguese, Italian, Russian, Czech, Indonesian and Spanish**, chosen per run from its language (`w:lang`, resolved through
the styles; Review › Language and the status bar set it: a short list, plus Set Proofing
Language… for every built-in language and every installed or available Language pack language, and Set As Default for the document and future blank documents). Languages without proofing tools get
Language… for any of 38 languages and Set As Default for the document and future blank documents). Languages without proofing tools get
the styles; Review › Language and the status bar set it). Languages without proofing tools get
no spelling or grammar marks (as in Word without the language's tools) instead of being flagged
as English. Detail in [Proofing per language](#proofing-per-language).
- Document scripts: shaping via HarfRust (HarfBuzz port) for every script; bidi (UAX #9) and Arabic
@@ -63,7 +64,7 @@ shared items below.
| Spanish | es | ~930; 98% / ~95% | most | Latin ✅ | no | partial | 1–2 |
| Hindi | hi | 0 | ❌ | Devanagari shaping via HarfRust, untested; no hyphenation | no | none | 4–6 |
| Arabic | ar | ~2,730 entries (craft-i18n `locales/ar/wordcraft.tsv`); 98% / ~95% | most; newer panes English | shaping, joining, lam-alef, bidi ✅ (#207); mirrored UI ✅ (#1195); Arabic UI font fallback; no kashida; no proofing | no | partial | 2–4 (review + kashida/proofing elsewhere) |
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (spelling and suggestions from the French Language pack, elided forms first: `Jadore` → `J'adore`; no grammar marks) | 1–2 (grammar) |
| French | fr | 2,615 entries (complete catalog) | all | Latin ✅ | no | partial (no proofing) | 1–2 (proofing) |
| Portuguese | pt-br (pt-PT none) | ~930; 98% / ~95% (pt-BR) | most | Latin ✅ | no | partial | 2–4 (pt-BR finish + pt-PT) |
| Indonesian | id | 0 | ❌ | Latin ✅ | no | none | 3–4 |
| Japanese | ja | ~930; 98% / ~95% | most | CJK ✅, IME ✅; ruby (Phonetic Guide) over its base text, kept by HTML export/import (#1681); word count counts each Han ideograph and kana as a word, not punctuation (#4547); no vertical text or document grid | no | partial | 3–5 |
@@ -129,9 +130,9 @@ Set Proofing Language › Set As Default keeps the chosen language in user prefe
| Language | Spelling | Suggestions | Grammar | Hyphenation |
|---|---|---|---|---|
| English: American (`en-US`, other regions, text with no language), British spelling (`en-GB`, `en-AU`, `en-NZ`, `en-IE`, `en-ZA`, `en-IN`…), Canadian (`en-CA`) | SCOWL 2026 (`rel-2026.02.25`, size 60) Hunspell dictionaries, one per English: en-US, en-GB `-ise` (also the other British-spelling Englishes) and en-CA, embedded from craft-i18n `language-data/` and read by the affix engine (ordinals such as `21st` and `112th` by `COMPOUNDRULE`). A word is right when the English's own dictionary or our supplement (`en-extra.txt`) knows it (never because another English's does: `colourblind` is wrong in en-US), unless our list of ~280 American/British pairs (craft-i18n [`language-data/en-us/en-variants.txt`](https://github.com/storytold/craft-i18n/blob/main/language-data/en-us/en-variants.txt)) marks it as the other dialect's: each English flags the other dialect's spellings (`colour` in en-US; `color`, `center`, `analyze` in en-GB; `organise` in en-CA), British English accepts `-ize` and `-ise` (Oxford spelling, as Word does; SCOWL's en-GB `-ise` alone would flag `organize`), and a dialect's form of a known word is right even if the list lacks it (`realisation`). Text with no language takes any English's word. An English Hunspell dictionary in File › Options › Proofing › Dictionaries folder comes first (the document's own English); without the SCOWL files, an installed Hunspell English, then the Moby word list (public domain, ~165k words) built into craft-proof. No system dictionary needed | the dialect's own spelling first (`colour` → `color`), then the dialect's SCOWL dictionary's suggestions (its TRY/REP tables, one typo, a stem scan; a missing space only between two words of 3+ letters, last), in the dialect's spelling | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic |
| English: American (`en-US`, other regions, text with no language), British spelling (`en-GB`, `en-AU`, `en-NZ`, `en-IE`, `en-ZA`, `en-IN`…), Canadian (`en-CA`) | SCOWL 2026 (`rel-2026.02.25`, size 60) Hunspell dictionaries, one per English: en-US, en-GB `-ise` (also the other British-spelling Englishes) and en-CA, embedded from craft-i18n `language-data/` and read by the affix engine (ordinals such as `21st` and `112th` by `COMPOUNDRULE`). A word is right when the English's own dictionary or our supplement (`en-extra.txt`) knows it (never because another English's does: `colourblind` is wrong in en-US), unless our list of ~280 American/British pairs (craft-i18n [`language-data/en-us/en-variants.txt`](https://github.com/storytold/craft-i18n/blob/main/language-data/en-us/en-variants.txt)) marks it as the other dialect's: each English flags the other dialect's spellings (`colour` in en-US; `color`, `center`, `analyze` in en-GB; `organise` in en-CA), British English accepts `-ize` and `-ise` (Oxford spelling, as Word does; SCOWL's en-GB `-ise` alone would flag `organize`), and a dialect's form of a known word is right even if the list lacks it (`realisation`). Text with no language takes any English's word. An English Hunspell dictionary in File › Options › Language › Your own dictionaries folder comes first (the document's own English); without the SCOWL files, an installed Hunspell English, then the Moby word list (public domain, ~165k words) built into craft-proof. No system dictionary needed | the dialect's own spelling first (`colour` → `color`), then the dialect's SCOWL dictionary's suggestions (its TRY/REP tables, one typo, a stem scan; a missing space only between two words of 3+ letters, last), in the dialect's spelling | repeated word, `a`/`an`, sentence capital, space before punctuation, double space | Moby dictionary, our Liang patterns, heuristic |
| Russian (`ru`) | A Russian Hunspell dictionary installed on the system (`ru_RU` from the OS, a package manager, LibreOffice's `dict-ru` or the `WORDCRAFT_DICTIONARIES` folder; UTF-8, KOI8-R or Windows-1251; not bundled, so no Russian spelling marks without it), read by the same affix engine; `ё` optional (`елка`/`ёлка`, `еще`/`ещё`); hyphenated words whole, then by parts (`кто-то`, `по-русски`, `кое-где`, `северо-западный`, `ИИ-консультант`); solid compounds (`высокоэластичный`), `не` with adjectives, participles and verbal nouns (not verbs), productive prefixes (`ультра-`, `эко-`) and prefixed verbs; abbreviations with endings (`ГИПа`) and camel case left alone; our supplement of ~200 modern words with their declensions (craft-i18n [`language-data/ru/ru-extra.txt`](https://github.com/storytold/craft-i18n/blob/main/language-data/ru/ru-extra.txt): `маркетплейс`, `промокод`, `интеграция`, `колеровать`) | `ё`/`е` first, then slips within letter groups (unstressed vowels, voiced and voiceless pairs, `ь`/`ъ`), the common confusions (`жы`, `чя`, `тся`/`ться`, doubled consonants, unpronounced consonants: `здраствуй` → `здравствуй`), a missing space (`незнаю` → `не знаю`; not a preposition before a verb), one typo, then a stem scan | Russian messages: repeated word, space before punctuation, double space, sentence capital (knows `т. е.`, `г.`, `руб.`, `ул.`, initials, numbers, dialogue dashes), possibly missing comma before `что`/`чтобы`/`если`/`хотя`/`поскольку`/`который` and before `потому что` (not after conjunctions and particles such as `и`, `только`, `даже`, `так`, `для того`; not after a preposition; not in `хотя бы`, `что-то`), doubled punctuation | The rules of Russian word division as code (no pattern file): a vowel on each side, two letters at least, `ь`/`ъ`/`й` kept with the letter before, a break after `ъ`, a consonant cluster divided after its first consonant |
| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Proofing › Dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; the same pane shows which file each language found), read at runtime by a Hunspell-style affix engine (craft-proof [`src/affix.rs`](https://github.com/storytold/craft-libs/blob/main/crates/proof/src/affix.rs) in craft-libs); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet |
| Polish (`pl`) | The Hunspell `pl_PL` dictionary installed on the system (OS package, Homebrew, LibreOffice, the folder set in File › Options › Language › Your own dictionaries folder, or `WORDCRAFT_DICTIONARIES`; not bundled, so no Polish spelling marks without it; Your languages there shows each language's proofing status), read at runtime by a Hunspell-style affix engine (craft-proof [`src/affix.rs`](https://github.com/storytold/craft-libs/blob/main/crates/proof/src/affix.rs) in craft-libs); names capitalized, compounds part by part, `Kennedy'ego` | missing diacritics first (`zolw` → `żółw`), the dictionary's confusion table (`żeka` → `rzeka`, `chuśtawka` → `huśtawka`), missing space (`napewno` → `na pewno`), one typo, then a stem scan within two typos | Polish messages: repeated word, space before punctuation, double space, sentence capital (knows `np.`, `r.`, initials, ordinals, dialogue dashes), missing comma before `że`/`który`/`ponieważ`/`gdyż`/`aby`/`żeby`/`jeśli`/`jeżeli`/`gdyby`/`lecz` (not after `mimo`, `chyba`, `tak`, prepositions…), `we`/`ze` (`we wtorek`, `ze szkoły`), doubled punctuation | TeX Polish patterns (Kołodziejska, Jackowski, Ryćko; MIT option) with their 20 exception words; a Polish syllable heuristic for letters outside the alphabet |
| Portuguese (`pt`, any region) | The Hunspell `pt_BR` dictionary installed on the system — VERO and its derivatives (same places as Polish; not bundled, LGPL/MPL), read at runtime by the same affix engine, which reads its `FLAG UTF-8`, 106 flags, continuation classes and `FORBIDDENWORD` spellings; names capitalized, compounds part by part (`guarda-roupa`, `ex-marido`) | missing accents first (`coraçao` → `coração`, `saude` → `saúde`), the dictionary's confusion table (`seje` → `seja`, `excessão` → `exceção`), missing space (`nãoé` → `não é`), one typo, then an accent-folding stem scan | Portuguese messages: repeated word (the emphatic doubles are allowed), space before punctuation, double space, sentence capital (knows `etc.`, `pág.`, `Dr.`, initials, ordinals, dialogue dashes), doubled punctuation; no comma rule — `que` opens a clause only sometimes | TeX Portuguese patterns (Rezende, Almeida, Araujo, Benevides; BSD 3-clause) with their 2 exception words |
| Czech (`cs`, `cs-CZ`, `ces`, `cze`) | The system Hunspell `cs_CZ` dictionary, read at runtime (Debian/Ubuntu: `hunspell-cs`; the same system, LibreOffice and `WORDCRAFT_DICTIONARIES` search paths as Polish). No dictionary data is bundled; without an installed dictionary spelling stays off | suggestions from the loaded dictionary's MAP/REP/TRY/KEY tables, missing spaces and a stem scan; short-word splits rank after deleting an extra letter; NOSUGGEST stems and their derived forms are accepted when typed but excluded from suggestions | Czech messages (including Czech quotation marks) for repeated words, space before punctuation and double spaces only; digit-space-colon-space-digit ratios keep their spaces; no Czech-specific grammar rules | existing English fallback, as for `Other`; no Czech patterns bundled (the TeX/LibreOffice Czech patterns are GPL-2) |
| Italian (`it`, any region) | The Hunspell `it_IT` dictionary installed on the system (same places as Polish and Portuguese; not bundled — the upstream file is GPL-licensed), read at runtime by the same affix engine; elided forms check the word after the apostrophe (`l'amico`, `un'idea`, `c'è`, `dov'è`, `senz'altro`), truncations keep their apostrophe (`po'`, `mo'`), hyphenated compounds part by part | missing accents first (`caffe` → `caffè`, `perche` → `perché`), the dictionary's confusion table, missing space (`perme` → `per me`), one typo, then an accent-folding stem scan | Italian messages: repeated word (emphatic doubles like `piano piano` are allowed), space before punctuation, double space, sentence capital (knows `ecc.`, `dott.`, `prof.`, initials, ordinals), doubled punctuation, `un pò` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'altro` → `un altro` and `un altra` → `un'altra` (gender from the ending, masculine `-ista`/`-ma`/`-cida`… left alone); these work without a dictionary; no comma rule | TeX Italian patterns (Beccari; MIT option), no exception words |
@@ -169,16 +170,18 @@ pack** (its spelling dictionary, hyphenation patterns and licence texts), listed
pinned pack index and installed through the font store into `<font store>/language-packs/<locale>/`
([installing languages](spelling-dictionaries.md#language-packs)):
- File › Options › Language lists the installed and available packs with name, size, licence and
source, and installs or removes them (`proof.packs`, `proof.installPack`, `proof.removePack` for
agents). A pack is read at once; the lookup order is the dictionaries folder, the installed pack,
- File › Options › Language › Your languages lists English, the installed packs and the
document's languages with each one's proofing status (Built in, Installed, Your own dictionary,
System dictionary, Grammar built in, Not available yet), and installs, removes or sets the default
(Set as default); Add a language… opens a searchable list of the other packs with name, size,
licence and source (`proof.packs` with its `languages` rows, `proof.installPack`,
`proof.removePack` for agents). A pack is read at once; the lookup order is the dictionaries folder, the installed pack,
then the system's Hunspell dictionaries.
- A document with text in a language whose pack is available but not installed shows a bar
offering it, and closes per document. Once the list of packs is known it never shows for a
language without a pack; before then (a fresh install) it offers a language with no dictionary
provisionally: Install fetches the list, then installs the pack if there is one, and the bar goes
if there isn't. The caret's typing language counts, and the spelling context menu's heading is in
the interface language.
if there isn't.
- Help › About › Language data shows the full licence texts of the embedded English data and the
installed packs.
- One language list: Set Proofing Language lists the built-in languages and every installed or
@@ -186,8 +189,7 @@ pinned pack index and installed through the font store into `<font store>/langua
name; Review › Language and the status bar's menu show the short list plus the installed packs'
languages. A pack's language is stored as the tag WordCraft names (`bg-BG`, `sr-Latn-RS`,
`de-AT`, Word's `ca-ES-valencia` for Valencian), which finds that pack again, so the text is
proofed by it; a bare tag (`nl`) is the same row as its regioned one (`nl-NL`), compared by tag
(language, script, region, variant), never by the name shown. Every pack in the published
proofed by it; a bare tag (`nl`) is the same row as its regioned one (`nl-NL`). Every pack in the published
index has an English name.
- language-packs-2 is published and pinned (75 packs, craft-i18n `20467b63`), adding the
compounding languages (Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian,
@@ -267,11 +269,9 @@ WordCraft keeps 2,54 cm).
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | craft-i18n.rev at 9d6d54d (craft-i18n#145): 69 region names and the `{language} ({region})` template (full-width in ja, ko, zh) in 35 locales, AI-assisted without native-speaker review |
| 2026-11-04 | trivial | craft-i18n.rev at cde4900 (craft-i18n#136): translated rows for Options › Language (Your languages, Add a language…, Set as default, Dictionary files), the missing-pack bar and 28 Language pack language names in 35 locales, AI-assisted without native-speaker review |
| 2026-11-03 | trivial | craft-proof pin moved to craft-libs main with #589 and #590: French suggestions offer the elided form first (`Jadore` → `J'adore` before `J adore`; English keeps plural fixes first), and Valencian (`ca-ES-valencia`) text reads the `ca-valencia` pack |
| 2026-11-03 | minor | Arabic (`ar`) interface: ~2,730 catalog rows from craft-i18n, `ar-*` locales, mirrored shell (ribbon, panes, File page, dialogs, menus), Arabic UI font fallback, document canvas independent (#1195); 28 languages; Hebrew UI still open |
| 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian); Valencian is `ca-ES-valencia`; rows are matched by tag, not by the name shown |
| 2026-11-03 | minor | Options › Language redesign (CP4.3): Your languages shows where each language's proofing comes from (Built in, Installed, Your own dictionary, System dictionary, Grammar built in, Not available yet); Add a language… replaces the Language packs table and Show available languages; Set as default; the dictionaries folder moved from Proofing |
| 2026-11-03 | trivial | Language lists: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English (Belarusian) |
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list of packs is fetched (a fresh install, or a list cached for an older pin) and gets it on Install; with downloading on, a missing or stale list is fetched once in the background; the caret's typing language counts; the spelling context menu's heading is in the interface language |
| 2026-11-03 | trivial | Language packs: language-packs-2 published and pinned, 75 packs, with Danish, German, Dutch, Swedish, Norwegian, Hungarian, Estonian, Croatian, Persian and Esperanto |
| 2026-11-02 | trivial | Language packs (CP4.4): language-packs-1 published and pinned, 61 packs; Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian available; Dutch, German and the other compounding languages wait on craft-proof; Basque, Ukrainian and Uyghur excluded |
@@ -303,7 +303,7 @@ WordCraft keeps 2,54 cm).
| 2026-10-14 | trivial | Print area translated in all 18 interface catalogs (printer list, duplex, grayscale, discovery/submission feedback, print commands) — 18 strings that shipped English-only with the craft-print integration; written from the English meaning, pending native-speaker review like the rest of the catalogs |
| 2026-10-14 | trivial | Italian: the interface audit's catalog check covers Italian too, as it does Russian, Hungarian, Turkish and Bulgarian; `Privacy Settings` no longer says "current" ("attuali"); a test pins the envelope size labels #1128 made translatable (Italian inches, the region's decimal comma) |
| 2026-10-14 | minor | Italian grammar rules that work without a dictionary: `un pò`/`un po` → `un po'`, `qual'è` → `qual è`, `E'` → `È`, `un'` only before feminine words (`un'altro` → `un altro`, `un altra` → `un'altra`, also `nessun`/`ciascun`/`qualcun`/`alcun`); spelling accepts more elisions (`dov'è`, `anch'io`, `senz'altro`, `nessun'altra`) and truncations (`po'`, `mo'`) and leaves `pò`/`qual'è` to grammar so they aren't marked twice; more emphatic doubles allowed |
| 2026-10-12 | minor | File › Options › Proofing › Dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) |
| 2026-10-12 | minor | File › Options › Language › Your own dictionaries folder (#1063): a saved folder of Hunspell dictionaries searched first, so an app started from Finder or the Dock finds them without `WORDCRAFT_DICTIONARIES`; the pane lists the dictionary file each language found — Polish, Portuguese, Italian, Croatian, Turkish (`file.options` `dictionariesFolder`) |
| 2026-10-12 | minor | Czech interface catalog completed: 386 entries added (2,447 total), including built-in style names, numbered families, equation gallery, watermarks, paper sizes and preview tiles; coverage test requires every Russian and Spanish key and retains Chinese coverage |
| 2026-10-14 | minor | Italian interface reviewed by a native speaker: Word names for tabs and commands, consistent terms (input penna, Disponi testo, Ombreggiatura), imperatives for commands |
| 2026-10-12 | minor | Italian proofing: it-IT spelling from the installed Hunspell dictionary (system `it_IT`, never bundled — upstream is GPL), accent-first suggestions, conservative grammar with Italian messages, TeX Italian hyphenation patterns (MIT option); Review › Language already offered Italian; an `it` interface writes it-IT documents |
+4 -4
View File
@@ -119,13 +119,13 @@ the suite's font store (see [installing languages](spelling-dictionaries.md#lang
| Command | Params | Returns |
|---|---|---|
| `proof.packs` | `{"refresh"?: bool}` (fetch the pinned index again) | `{"packs": [{"locale", "name", "size", "licence", "class", "source" (upstream), "archive", "spelling", "hyphenation", "installed"}], "installed": [...], "installing": [{"locale", "done", "total"}], "refreshing", "errors": {locale: message}, "network", "available"}` |
| `proof.packs` | `{"refresh"?: bool}` (fetch the pinned index again) | `{"packs": [{"locale", "name", "size", "licence", "class", "source" (upstream), "archive", "spelling", "hyphenation", "installed"}], "languages": [{"tags", "pack", "installed", "size", "proofing" (built_in, installed, own_dictionary, system_dictionary, install, grammar_built_in, not_available), "default"}] (Options › Language › Your languages), "installed": [...], "installing": [{"locale", "done", "total"}], "refreshing", "errors": {locale: message}, "network", "available"}` |
| `proof.installPack` | `{"locale": "nl"}` | `{"locale", "name", "licence", "installed": true}` (`"already": true` when it was); with `--connect`, `{"locale", "started": true}` |
| `proof.removePack` | `{"locale": "nl"}` | `{"locale", "installed": false}` |
None opens anything. Downloading is the font store's single consent setting: an agent can install
only while the person has turned it on in the app (File › Options › Language: Install or Show
available languages), and can never turn it on itself; otherwise `proof.installPack` fails with
only while the person has turned it on in the app (File › Options › Language: Install or Add a
language…), and can never turn it on itself; otherwise `proof.installPack` fails with
*downloading is turned off*. `refresh` likewise needs it on. Every archive is checked against the
sha256 in the pinned index before it is unpacked. With `--connect` the commands act on the running
app's packs, and an install or a `refresh` runs in the background so the window keeps working: the
@@ -134,7 +134,7 @@ command returns `"started": true` at once, and `proof.packs` shows it under `ins
pack's install ends. Headless, they act on the same font store folder and return when done. The
web build has no packs.
A click or key sent with `click`/`key` on Install, Show available languages or Refresh never turns
A click or key sent with `click`/`key` on Add a language…, Install or Refresh never turns
downloading on either: only the person's own click does; the app shows why instead.
Installing reads the pinned index of craft-i18n's language-packs-2 release (75 languages). For
+18 -14
View File
@@ -1,6 +1,6 @@
# Installing languages and spelling dictionaries
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-11-03 · **Change:** trivial (Language packs are installed from Your languages and Add a language…; the dictionaries folder is in Options › Language); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland)); previously trivial (Language packs: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); Mongolian and Thai aren't packs yet); previously trivial (Language packs: the first set, language-packs-1, is published and pinned: 61 languages, and which aren't packs yet); previously minor (Language packs: install a language's spelling and hyphenation from File › Options › Language, or from the bar a document in that language shows; they are searched after the dictionaries folder and before the system's dictionaries; previously trivial: the Flatpak's own dictionaries folder; previously trivial: WordCraft's own dictionaries folder is searched on macOS and Linux too, as on Windows, so a dictionary dropped there works without a setting; previously minor: ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824)
English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian,
Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that
@@ -27,19 +27,22 @@ built in; every other language is a pack.
> split Thai text, which has no spaces, into words). Basque,
> Ukrainian and Uyghur aren't packs: their data's licence doesn't allow it, or isn't clear.
- **File › Options › Language › Language packs** lists the packs, each with its size, its licence
and a link to where it comes from. Click **Install** next to a language; **Remove** takes it out
again. The first time, click **Show available languages** to get the list.
- **File › Options › Language › Your languages** lists English, the languages of the open
document and every installed pack, each with where its proofing comes from (*Built in*,
*Installed*, *Your own dictionary*, *System dictionary*, *Grammar built in*, *Not available yet*).
**Install** gets a document language's pack; **Remove** takes an installed one out; **Set as
default** makes it the language of new blank documents (the open document is left as it is). **Add a language…** opens a list of the other packs: type *dutch*, *nederlands* or
*nl* to search, see each pack's size, licence and a link to where it comes from, and click
**Install**.
- When a document has text in a language whose pack isn't installed, a bar under the ribbon says
*Dutch proofing isn't installed.* with an **Install** button showing the download size. ✕ hides
it for that document. Once WordCraft has the list of packs, the bar shows only for a language
that has a pack. Before the list has been fetched (a fresh install, or the list of an older
WordCraft), it can't know, so it shows for a language that has no dictionary, with **Install**
alone: clicking it gets the list, then the pack if there is one (if not, the bar goes). With
downloading on, WordCraft fetches a missing or out-of-date list by itself once. Choosing a
language with nothing selected counts too.
it for that document. It never shows for a language that has no pack. Before the list of
packs has been fetched (a fresh install, or the list of an older WordCraft), it shows for a
language that has no dictionary, with **Install** alone: clicking it gets the list, then the
pack if there is one (if not, the bar goes). With downloading on, WordCraft fetches a missing
or out-of-date list by itself once. Choosing a language with nothing selected counts too.
- Installing needs **downloading** on: the same single setting as WordCraft's font downloads
(the font store). Clicking Install, Show available languages or Refresh turns it on; nothing is
(the font store). Clicking Add a language…, Install or Refresh turns it on; nothing is
downloaded before. Each pack is checked against the sha256 in WordCraft's pinned list before it
is used.
- Text in that language is checked and hyphenated as soon as the pack is installed: no restart.
@@ -92,9 +95,9 @@ Turkish `tr_TR` dictionaries ISO 8859-9).
The first folder holding a matching pair wins:
1. The folder set in **File › Options › Proofing › Dictionaries folder**. This is the easiest
way on every system, and it works for an app started from the Start menu, Finder or the Dock.
The same pane lists the dictionary file each language found.
1. The folder set in **File › Options › Language › Your own dictionaries folder**. This is the
easiest way on every system, and it works for an app started from the Start menu, Finder or
the Dock. Your languages marks each language it supplies *Your own dictionary*.
2. The folder in the `WORDCRAFT_DICTIONARIES` environment variable, and WordCraft's own
`dictionaries` folder (the first row of each system below).
3. An installed [Language pack](#language-packs) for the language.
@@ -188,6 +191,7 @@ Never commit dictionary files to this repository: they stay on the user's system
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | trivial | Language packs: install from Your languages or Add a language… in Options › Language; the dictionaries folder is in Options › Language, not Proofing |
| 2026-11-03 | trivial | Language packs (CP4.3): the missing-pack bar offers a language before the list is fetched and gets the list, then the pack, on Install; a missing or stale list is fetched once while downloading is on; the caret's typing language counts |
| 2026-11-03 | trivial | Language packs: language-packs-2 is published and pinned (75 languages, adding the compounding languages); Mongolian and Thai aren't packs yet, and why |
| 2026-11-02 | trivial | Language packs: language-packs-1 is published and pinned (61 languages); which languages aren't packs yet and why (CP4.4) |
+2 -1
View File
File diff suppressed because one or more lines are too long
@@ -126,6 +126,6 @@ modules:
# the two equal).
- type: git
url: https://github.com/storytold/craft-i18n
commit: 9d6d54d6d9ff603cc41e12e5f6ba51af3554ab66
commit: 059cbf95c2a3eb95d637ca8584faca1129d17f48
dest: craft-i18n
- cargo-sources.json