CP4.3: a language and region name is translated in parts, not looked up whole

Swedish (Finland) is built at run time, so no catalog row can match it. One helper
(ribbon::display_name) now translates the language, the region and the {language} ({region})
template separately and joins them; the Set Proofing Language list, the Language menu, the status
bar and the dialog all use it. English output is unchanged and matching never uses a name.
gap 8 no longer describes the missing-pack bar, which is in its own branch.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FypgCYmv6UJmY2QcuhfXco
This commit is contained in:
Martin BallandClaude Sonnet 5.5 committed 2026-11-04 01:14:05 +00:00
1 parent 26a8182e91
commit 29f99006e0
6 files changed
+81 -18

No files matched your search

@@ -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 {
tl!(&crate::ribbon::language_name(Some(tag))).to_string()
crate::ribbon::display_name(Some(tag))
}
pub(super) fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
+2 -3
View File
@@ -142,9 +142,8 @@ 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);
let named = crate::ribbon::language_name(Some(&tag));
if !named.eq_ignore_ascii_case(&tag) {
return tl!(&named).to_string();
if !crate::ribbon::language_name(Some(&tag)).eq_ignore_ascii_case(&tag) {
return crate::ribbon::display_name(Some(&tag));
}
if endonym.is_empty() { pack_locale.to_string() } else { endonym.to_string() }
}
+1 -1
View File
@@ -57,7 +57,7 @@ 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,
};
pub(crate) use language::{language_list, language_name, same_language, tag_for_pack};
pub(crate) use language::{display_name, language_list, language_name, same_language, tag_for_pack};
use layout::layout;
#[cfg(test)]
use layout::{columns_items, margins_items, orientation_items};
+50 -12
View File
@@ -96,7 +96,7 @@ pub(crate) fn short_languages() -> Vec<(String, String)> {
more.push((tag, name));
}
}
more.sort_by_cached_key(|(_, n)| sort_key(tl!(n)));
more.sort_by_cached_key(|(t, n)| sort_key(&display_of(t, n)));
out.extend(more);
out
}
@@ -113,7 +113,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, tl!(&name).to_string()));
out.push((tag.clone(), display_of(&tag, &name)));
}
}
out.sort_by_cached_key(|(_, n)| sort_key(n));
@@ -126,7 +126,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(), tl!(&language_name(Some(current))).to_string()));
list.push((current.to_string(), display_name(Some(current))));
list.sort_by_cached_key(|(_, n)| sort_key(n));
}
list
@@ -170,15 +170,53 @@ 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 name.to_string();
return NameParts::Whole(name.to_string());
}
let mut parts = tag.split(['-', '_']);
let primary = parts.next().unwrap_or("");
@@ -191,12 +229,12 @@ pub(crate) fn language_name(tag: Option<&str>) -> String {
.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) => format!("{language} ({})", region_name(r)),
None => tag.to_string(),
Some(language) => NameParts::Regioned { language: language.to_string(), region: region_name(r) },
None => NameParts::Whole(tag.to_string()),
}
}
// A bare language subtag ("pt", "pl"): the variant WordCraft proofs.
None => match wordcraft_engine::proof::ProofLang::from_tag(Some(tag)) {
None => NameParts::Whole(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(),
@@ -213,7 +251,7 @@ pub(crate) fn language_name(tag: Option<&str>) -> String {
_ => named_starting(tag)
.or_else(|| known().find(|(t, _)| t.contains('-') && wordcraft_engine::packs::without_region(t).eq_ignore_ascii_case(tag)))
.map_or_else(|| tag.to_string(), |(_, n)| (*n).to_string()),
},
}),
}
}
@@ -295,7 +333,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 {
tl!(&language_name(caret_lang_tag(app).as_deref())).to_string()
display_name(caret_lang_tag(app).as_deref())
}
/// The formatting at the caret, resolved through the styles and the document default.
@@ -349,11 +387,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 let Some(name) = checks.extra {
mi_check(ui, app, &name, true, "review.language", json!({"lang": current}));
if checks.extra.is_some() {
mi_check(ui, app, &display_name(Some(&current)), true, "review.language", json!({"lang": current}));
}
for ((tag, name), on) in entries.iter().zip(checks.listed) {
mi_check(ui, app, name, on, "review.language", json!({"lang": tag}));
mi_check(ui, app, &display_of(tag, 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}));
+26
View File
@@ -308,6 +308,32 @@ fn rows_are_matched_by_tag_not_by_display_name() {
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]
+1 -1
View File
@@ -71,7 +71,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
| 6 | **Print dialog depth**: desktop printer choice, copies, collation, duplex and grayscale submit through craft-print; File › Print retains preview, page ranges and odd/even (#641) and scales pages to another paper size (#2108); driver tray/options and pages per sheet remain; web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 7 | **Dialog depth**: Word-observed spelling/grammar task pane added (shared review commands, current-story traversal; see [spelling-review.md](spelling-review.md)); 61 modal dialogs vs Word's ~100 (Custom Document Properties from File › Info, #2982; Drop Cap Options matches Word's menu and dialog; Split Cells and Convert Text to Table match Word's observed limits and defaults; Format Page Numbers carries Word's number formats and Continue from previous section / Start at, #1113) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs`; Cell Margins (#1960) now opens with current defaults and applies only confirmed edits | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [ui-parity.md](ui-parity.md) |
| 9 | **Password-protected `.doc` files with an AES header in the Word 97-2003 format** can't be opened: [MS-OFFCRYPTO] defines AES only for encrypted packages, so these are refused with a clear message. Encrypted packages open under any Word extension (`.docx`, `.doc`, `.dot`…) with either agile (Word 2010+) or Standard (Word 2007, AES-ECB) encryption; protected `.docx` saves with agile encryption (#55, #281, gap 9) | | `crates/docx/src/crypt.rs` (agile and Standard); `crates/docbin/src/crypt.rs` opens XOR/RC4/RC4 CryptoAPI `.doc` with a password (#1079). All synthetic fixtures; untested on Word-written files | Corporate and legal users with old files | 0 (refused by design; nothing left to build) | [file-format-parity.md](file-format-parity.md) |
| 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once (the bar offers a language before the list is fetched, and the caret's typing language counts); language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) |
| 8 | **Built-in proofing covers English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish only**; the other languages Word proofs get spelling and their own hyphenation only from an installed Language pack (without one their text is left unmarked and hyphenated with the English patterns; Bulgarian and German have their own), and none of them has grammar rules | | craft-proof (storytold/craft-libs `crates/proof`, data in craft-i18n `language-data/`) `ProofLang::PROOFED`: English (American, British-spelling and Canadian, each checked against its own SCOWL 2026 dictionary and flagging the other's spellings, #1281, #40, #520), Polish, Portuguese (pt-BR via the installed VERO dictionary, #797), Italian (it-IT via the installed Hunspell dictionary), Croatian (hr-HR, 2026-10-13), Turkish (tr_TR spelling, grammar, TDK hyphenation, 2026-10-13) Russian (the installed `ru_RU` dictionary, 2026-10-12) Indonesian (the installed `id_ID` dictionary, #498, 2026-10-16) and Spanish (es-ES, es-MX, es-419 and es-UY via the installed RLA-ES dictionaries, Uruguayan `vos` forms, #3360, #3553, 2026-10-22), a language per run; Bulgarian (#826) and German (TeX patterns, 2026-10-14) have hyphenation only; open PRs add Danish (#723), Russian (#894) and Czech (#1048); Word ships 120 proofing tools; issues #25, #100 open. **Language packs** (CP4, 2026-11-01): a pack per locale (spelling dictionary, hyphenation, licence texts) installs from File › Options › Language or the missing-pack bar through the font store and is read at once; language-packs-1 (pinned, 61 packs) has Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707), Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438), Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947), French [#1325](https://github.com/storytold/wordcraft/issues/1325), Greek [#2561](https://github.com/storytold/wordcraft/issues/2561), Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806), Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438), with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian and the rest; Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505), German, Danish, Swedish, Norwegian, Hungarian and the other compounding languages are packs in language-packs-2 (craft-proof's flag-based compounding, storytold/craft-libs#497); Mongolian waits on faster suggestions, Thai on word segmentation, and Basque, Ukrainian and Uyghur are excluded on licence grounds (see [installing languages](spelling-dictionaries.md#language-packs)) | Every writer outside English, Polish, Portuguese, Italian, Croatian, Turkish, Russian, Czech, Indonesian and Spanish | 20–40 | [localization-parity.md](localization-parity.md) |
| 10 | **Objects**: text in a freely rotated text box (free rotation and flips landed, #332; live rotation preview while dragging fixed for pictures and shapes without text stories, #2277; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt 3-D effects and connector routing around shapes (WordArt effects and warps and glued connectors landed, #416), dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 11/11, Layout 22/22); group and ungroup landed (#267) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
| 11 | **Interface languages**: 2 of the 12 key languages missing (hi, id) and pt-PT (Arabic landed with a mirrored shell, #1195; French landed, #729; Vietnamese, #1434; Korean, #1216; 28 interface languages in `crates/ui-egui/src/i18n`); open PRs for Indonesian (#572), pt-PT (#1028) and Hebrew with RTL (#20); ~5% of strings still English in the translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 40–70 | [localization-parity.md](localization-parity.md) |
| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); Ink to Shape also recognises single-stroke arrows (a line plus a V head drawn in one stroke becomes a straight connector with an arrowhead); two-stroke arrows (a line and a separate V or closed-triangle head) and one-stroke arrows with a closed triangle head landed (#1778); missing: Ink to Math, pressure, rendering/editing Word's own ink (drawing-level and direct run-level InkML content parts now survive DOCX save) | | [parity-checklist.md](parity-checklist.md) (Draw 10/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) |