Browse / github.com/storytold/vectorcraft / patch
i18n: one Cargo feature per interface language, all on by default
i18n: one Cargo feature per interface language, all on by default ## What and why The 23 interface catalogs in `crates/ui-egui/src/i18n` are about 6.6 MB of every binary that links `vectorcraft-ui-egui`: 2.0 MB with `gzip -9` and 1.1 MB with `brotli -q 11`, measured with `cat crates/ui-egui/src/i18n/*.tsv | gzip -9 | wc -c`. We embed the panels in a browser editor, where every megabyte of WebAssembly is downloaded, and it ships every catalog whichever languages its own interface has. This follows the shape of #2027 (`bundled-fonts`): a default-on feature, with an opt-out for embedders. **What changes** - **A feature per language** (`crates/ui-egui/Cargo.to
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- addibble (imported from pull request #3432, open)
- Base
8622d0c71c18a166d666314058be7f0e3e9b290f- Size
- +389 −61 in 23 files
- Review
- pending
- Shared
- 2026-10-28
- Id
5d6baeb6bce71fd75eb11820
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/Cargo.toml crates/ui-egui/src/chrome/tests.rs crates/ui-egui/src/dialogs/tests.rs crates/ui-egui/src/dialogs/tests_modal.rs crates/ui-egui/src/font_menu/tests.rs crates/ui-egui/src/i18n.rs crates/ui-egui/src/i18n/catalog.rs crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/tests_compiled_in.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/menus/tests.rs crates/ui-egui/src/palette.rs crates/ui-egui/src/panels/appearance/tests.rs crates/ui-egui/src/panels/character/tests.rs crates/ui-egui/src/panels/history.rs crates/ui-egui/src/panels/layers.rs crates/ui-egui/src/panels/layers/tests.rs crates/ui-egui/src/panels/variables.rs crates/ui-egui/src/prefs_dialog.rs crates/ui-egui/src/tests/nativemenu.rs crates/ui-egui/src/toolbar/tests.rs docs/development.md xtask/src/main.rs
@@ -9,7 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap workspace = true [features]-default = ["bundled-fonts", "interface-fonts"]+default = ["bundled-fonts", "interface-fonts", "translations"] # The fonts vectorcraft-text bundles for documents, through the engine's `bundled-fonts`. Off, for # an embedder that supplies every document font at run time (`vectorcraft_text::FontDb::add_font`); # the interface's own faces are `interface-fonts`.@@ -19,6 +19,35 @@ bundled-fonts = ["vectorcraft-engine/bundled-fonts"] # `theme::install_fonts` binds their families either way. interface-fonts = [] +# The interface's translations (`src/i18n/*.tsv`, about 6.6 MB of the binary), one feature per+# language (`lang-<code>`, the catalog's file name); `translations` turns on every one. A language+# whose feature is off is left out and not offered; with none on, the interface is English only.+translations = ["lang-bg", "lang-cs", "lang-da", "lang-de", "lang-el", "lang-es", "lang-fi", "lang-fr", "lang-hu", "lang-id", "lang-it", "lang-ja", "lang-ka", "lang-ko", "lang-nl", "lang-pl", "lang-pt-br", "lang-ru", "lang-sv", "lang-tr", "lang-uk", "lang-vi", "lang-zh-hans", "lang-zh-hant"]+lang-bg = []+lang-cs = []+lang-da = []+lang-de = []+lang-el = []+lang-es = []+lang-fi = []+lang-fr = []+lang-hu = []+lang-id = []+lang-it = []+lang-ja = []+lang-ka = []+lang-ko = []+lang-nl = []+lang-pl = []+lang-pt-br = []+lang-ru = []+lang-sv = []+lang-tr = []+lang-uk = []+lang-vi = []+lang-zh-hans = []+lang-zh-hant = []+ [dependencies] craft-ui = { workspace = true } craft-keymap = { workspace = true, features = ["egui"] }@@ -72,6 +101,8 @@ vectorcraft-testkit = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } wat = { workspace = true }+# i18n::tests_compiled_in reads this manifest's language features (already a build-dependency).+toml = "1" [build-dependencies] serde_json = { workspace = true }@@ -117,6 +117,8 @@ fn status_width_measurements_are_reused_across_frames() { } /// Each independent cache-key component refreshes widths, then subsequent frames reuse them.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn status_width_measurements_refresh_for_language_units_and_dpi() { let (mut app, ctx) = status_readout_app();@@ -518,6 +518,8 @@ fn menu_parameter_dialogs_are_compact() { /// A list mixing built-in labels with names translates only the built-in entries: names that /// happen to be catalog keys ("Black", "Regular") are shown as they are.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn mixed_lists_translate_only_their_built_in_entries() { use crate::i18n::{Lang, tr};@@ -696,6 +696,8 @@ fn simplify_dialog_show_original_path_outlines_the_paths_before_the_preview() { assert!(super::path_ops::original_paths(&app).is_empty()); } +// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn printer_mark_labels_are_distinct_from_language_names() { let cs = crate::i18n::Lang::from_code("cs").unwrap();@@ -1,5 +1,7 @@ /// The script filter: Thai lists families with Thai; Latin, those with neither Thai nor /// Japanese.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn the_thai_filter_lists_fonts_with_thai() { use vectorcraft_text::FontTraits;@@ -226,6 +228,8 @@ fn expanded_style_rows_do_not_retarget_the_highlight() { } /// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn the_variable_filter_is_named_as_fonts_are() { use crate::i18n::{Lang, tr, tr_ctx};@@ -7,7 +7,10 @@ //! # Adding a language //! 1. Add `xx.tsv` next to `zh-hant.tsv` (copy its header; translate from the *meaning* of the //! English text, clean-room, see `zh-hant.tsv`).-//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).+//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule), its catalog as+//! `catalog_tsv!("i18n/xx.tsv", "lang-xx")`, and the feature `lang-xx` to `Cargo.toml`, listed in+//! `translations`, so an embedder may leave it out, and to `FEATURE_ON` in+//! `tests_compiled_in.rs`. //! //! That is all: the Preferences dropdown, the system-locale match, the catalog tests (parse, //! placeholders, plural forms) and the untranslated report pick it up from the registry.@@ -26,6 +29,18 @@ mod catalog; +/// A language's catalog, compiled in with its feature (`lang-<code>`; `translations` turns on every+/// one). Without it the catalog is empty, and [`Lang::all`] and [`Lang::from_code`] skip the language.+macro_rules! catalog_tsv {+ ($file:literal, $feature:literal) => {{+ #[cfg(feature = $feature)]+ let source = include_str!($file);+ #[cfg(not(feature = $feature))]+ let source = "";+ source+ }};+}+ use std::sync::OnceLock; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -37,11 +52,13 @@ pub struct LangInfo { pub code: &'static str, /// The language's name in itself, shown in the Preferences dropdown. pub name: &'static str,- /// Catalog file contents (empty for the built-in English).+ /// Catalog file contents: empty for the built-in English and for a language whose feature+ /// (`lang-<code>`) is off, which the interface then doesn't offer. pub source: &'static str, /// Plural form index for a count (English: 0 = one, 1 = other; Chinese: always 0). pub plural: fn(u64) -> usize,- /// Does the catalog aim to cover every menu string? The untranslated report lists what it lacks.+ /// Does the catalog aim to cover every menu string? The untranslated report lists what it lacks+ /// ([`Lang::complete_menus`] is false for a language left out). pub complete_menus: bool, catalog: OnceLock<Catalog>, }@@ -93,7 +110,10 @@ fn plural_polish(n: u64) -> usize { } } -/// The registry. English first: it is the fallback and the source language.+/// The registry. English first: it is the fallback and the source language. Every registered+/// language is here, one left out of the build too (its `source` empty, its `complete_menus` as+/// registered): [`Lang::all`] lists the languages the build offers, and [`Lang::complete_menus`]+/// answers for the build. pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, // Greek: the whole interface and the status and error messages, keeping the same names in@@ -101,7 +121,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "el", name: "Ελληνικά",- source: include_str!("i18n/el.tsv"),+ source: catalog_tsv!("i18n/el.tsv", "lang-el"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -109,20 +129,30 @@ pub static LANGUAGES: [LangInfo; 25] = [ // Japanese: the whole interface (every menu string and `tl!` literal) and the status and error // messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`). LangInfo {- code: "ja", name: "日本語", source: include_str!("i18n/ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()+ code: "ja",+ name: "日本語",+ source: catalog_tsv!("i18n/ja.tsv", "lang-ja"),+ plural: plural_none,+ complete_menus: true,+ catalog: OnceLock::new(), }, // Korean: the whole interface and the status and error messages, keeping the same names in // English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an // installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here. LangInfo {- code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()+ code: "ko",+ name: "한국어",+ source: catalog_tsv!("i18n/ko.tsv", "lang-ko"),+ plural: plural_none,+ complete_menus: true,+ catalog: OnceLock::new(), }, // Vietnamese: complete menus and messages, with one plural form. // Every `vi-*` locale resolves here. LangInfo { code: "vi", name: "Tiếng Việt",- source: include_str!("i18n/vi.tsv"),+ source: catalog_tsv!("i18n/vi.tsv", "lang-vi"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(),@@ -132,7 +162,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "cs", name: "Čeština",- source: include_str!("i18n/cs.tsv"),+ source: catalog_tsv!("i18n/cs.tsv", "lang-cs"), plural: plural_czech, complete_menus: true, catalog: OnceLock::new(),@@ -142,7 +172,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "de", name: "Deutsch",- source: include_str!("i18n/de.tsv"),+ source: catalog_tsv!("i18n/de.tsv", "lang-de"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -152,7 +182,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "es", name: "Español",- source: include_str!("i18n/es.tsv"),+ source: catalog_tsv!("i18n/es.tsv", "lang-es"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -162,7 +192,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "fr", name: "Français",- source: include_str!("i18n/fr.tsv"),+ source: catalog_tsv!("i18n/fr.tsv", "lang-fr"), plural: plural_french, complete_menus: true, catalog: OnceLock::new(),@@ -172,7 +202,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "it", name: "Italiano",- source: include_str!("i18n/it.tsv"),+ source: catalog_tsv!("i18n/it.tsv", "lang-it"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -183,7 +213,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "ka", name: "ქართული",- source: include_str!("i18n/ka.tsv"),+ source: catalog_tsv!("i18n/ka.tsv", "lang-ka"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(),@@ -193,7 +223,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "ru", name: "Русский",- source: include_str!("i18n/ru.tsv"),+ source: catalog_tsv!("i18n/ru.tsv", "lang-ru"), plural: plural_east_slavic, complete_menus: true, catalog: OnceLock::new(),@@ -203,7 +233,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "uk", name: "Українська",- source: include_str!("i18n/uk.tsv"),+ source: catalog_tsv!("i18n/uk.tsv", "lang-uk"), plural: plural_east_slavic, complete_menus: true, catalog: OnceLock::new(),@@ -213,7 +243,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "bg", name: "Български",- source: include_str!("i18n/bg.tsv"),+ source: catalog_tsv!("i18n/bg.tsv", "lang-bg"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -221,14 +251,21 @@ pub static LANGUAGES: [LangInfo; 25] = [ // Hungarian: the whole interface and the status and error messages, keeping the same names in // English as Spanish; a count takes the singular ("3 réteg"), so plurals have one form. Every // `hu-*` locale (including `hu-HU`) resolves here.- LangInfo { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },+ LangInfo {+ code: "hu",+ name: "Magyar",+ source: catalog_tsv!("i18n/hu.tsv", "lang-hu"),+ plural: plural_none,+ complete_menus: true,+ catalog: OnceLock::new(),+ }, // Polish: the whole interface and the status and error messages, with one/few/many plurals, // keeping the same names in English as Spanish; every `pl-*` locale (including `pl-PL`) // resolves here. LangInfo { code: "pl", name: "Polski",- source: include_str!("i18n/pl.tsv"),+ source: catalog_tsv!("i18n/pl.tsv", "lang-pl"), plural: plural_polish, complete_menus: true, catalog: OnceLock::new(),@@ -238,7 +275,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "sv", name: "Svenska",- source: include_str!("i18n/sv.tsv"),+ source: catalog_tsv!("i18n/sv.tsv", "lang-sv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -249,7 +286,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "pt-br", name: "Português (Brasil)",- source: include_str!("i18n/pt-br.tsv"),+ source: catalog_tsv!("i18n/pt-br.tsv", "lang-pt-br"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -259,7 +296,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "zh-hant", name: "繁體中文",- source: include_str!("i18n/zh-hant.tsv"),+ source: catalog_tsv!("i18n/zh-hant.tsv", "lang-zh-hant"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(),@@ -269,7 +306,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "zh-hans", name: "简体中文",- source: include_str!("i18n/zh-hans.tsv"),+ source: catalog_tsv!("i18n/zh-hans.tsv", "lang-zh-hans"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(),@@ -277,7 +314,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "nl", name: "Nederlands",- source: include_str!("i18n/nl.tsv"),+ source: catalog_tsv!("i18n/nl.tsv", "lang-nl"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -286,7 +323,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "da", name: "Dansk",- source: include_str!("i18n/da.tsv"),+ source: catalog_tsv!("i18n/da.tsv", "lang-da"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -296,7 +333,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "id", name: "Bahasa Indonesia",- source: include_str!("i18n/id.tsv"),+ source: catalog_tsv!("i18n/id.tsv", "lang-id"), plural: plural_none, complete_menus: true, catalog: OnceLock::new(),@@ -307,7 +344,7 @@ pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "fi", name: "Suomi",- source: include_str!("i18n/fi.tsv"),+ source: catalog_tsv!("i18n/fi.tsv", "lang-fi"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new(),@@ -316,16 +353,43 @@ pub static LANGUAGES: [LangInfo; 25] = [ // English as Spanish; a count takes the singular ("3 katman"), so plurals have one form. Every // `tr-*` locale (including `tr-TR` and `tr-CY`) resolves here. LangInfo {- code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()+ code: "tr",+ name: "Türkçe",+ source: catalog_tsv!("i18n/tr.tsv", "lang-tr"),+ plural: plural_none,+ complete_menus: true,+ catalog: OnceLock::new(), }, ]; +/// Is the language `code`, whose catalog as compiled in is `source`, offered? English, the source+/// language, always is; another only when its feature compiled its catalog in.+fn offered(code: &str, source: &str) -> bool {+ code == "en" || !source.is_empty()+}+ impl LangInfo {+ /// Is this language's catalog in the binary? English (the source language) always is; another+ /// only with its feature (`lang-<code>`).+ fn compiled_in(&self) -> bool {+ offered(self.code, self.source)+ }+ fn catalog(&self) -> &Catalog { self.catalog.get_or_init(|| Catalog::parse(self.source)) } } +/// The languages of `table` the interface offers ([`Lang::all`] over [`LANGUAGES`]).+fn all_in(table: &'static [LangInfo]) -> impl Iterator<Item = Lang> {+ table.iter().filter(|l| l.compiled_in()).map(Lang)+}++/// An offered language of `table` by its exact code ([`Lang::from_code`] over [`LANGUAGES`]).+fn from_code_in(table: &'static [LangInfo], code: &str) -> Option<Lang> {+ all_in(table).find(|l| l.code().eq_ignore_ascii_case(code))+}+ /// A language the UI can be shown in (a handle into [`LANGUAGES`]). #[derive(Clone, Copy)] pub struct Lang(&'static LangInfo);@@ -353,7 +417,7 @@ impl Lang { /// A language by its exact code. pub fn from_code(code: &str) -> Option<Lang> {- LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)+ from_code_in(&LANGUAGES, code) } /// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything@@ -362,9 +426,10 @@ impl Lang { Lang::from_code(pref).unwrap_or_else(system_lang) } - /// Every registered language.+ /// Every language the interface offers: English and each language whose feature+ /// (`lang-<code>`) compiled its catalog in; every registered one with `translations`. pub fn all() -> impl Iterator<Item = Lang> {- LANGUAGES.iter().map(Lang)+ all_in(&LANGUAGES) } /// Every registered language in the order a picker lists them: English first (the source@@ -381,9 +446,10 @@ impl Lang { self.0.name } - /// Does this language's catalog claim to cover every menu string and `tl!` literal?+ /// Does this language's catalog claim to cover every menu string and `tl!` literal? Never+ /// for a language whose catalog is left out (its `lang-<code>` feature off). pub fn complete_menus(self) -> bool {- self.0.complete_menus+ self.0.complete_menus && self.0.compiled_in() } fn catalog(self) -> &'static Catalog {@@ -412,11 +478,21 @@ fn candidates(tag: &str) -> Vec<String> { /// The registered language for a locale tag such as `ja_JP.UTF-8`, `ja-JP`, `zh-TW`; `None` if /// the language isn't supported. `C`/`POSIX` mean English. pub fn lang_from_tag(tag: &str) -> Option<Lang> {+ lang_from_tag_in(&LANGUAGES, tag)+}++/// [`lang_from_tag`] among the offered languages of `table`.+fn lang_from_tag_in(table: &'static [LangInfo], tag: &str) -> Option<Lang> { let cands = candidates(tag); if matches!(cands.first().map(String::as_str), Some("c" | "posix")) { return Some(Lang::EN); }- cands.iter().find_map(|c| Lang::from_code(c))+ cands.iter().find_map(|c| from_code_in(table, c))+}++/// The first of the system's preferred languages `tags` that `table` offers, else English.+fn preferred_in<S: AsRef<str>>(table: &'static [LangInfo], tags: &[S]) -> Lang {+ tags.iter().find_map(|t| lang_from_tag_in(table, t.as_ref())).unwrap_or(Lang::EN) } /// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on@@ -457,7 +533,7 @@ pub fn system_lang() -> Lang { /// the system language (English if none is); call it before the first frame. Has no effect once /// the system language is known. pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {- let lang = tags.iter().find_map(|t| lang_from_tag(t.as_ref())).unwrap_or(Lang::EN);+ let lang = preferred_in(&LANGUAGES, tags); // Already set means already detected (or reported): the first answer stands. let _ = SYSTEM.set(lang); }@@ -661,6 +737,8 @@ pub fn tn(n: u64, one: &str, other: &str) -> String { #[cfg(test)] mod tests;+#[cfg(test)]+mod tests_compiled_in; /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are.@@ -209,8 +209,8 @@ impl Catalog { } } -/// `{name}` placeholders of a template, in order of appearance.-#[cfg(test)]+/// `{name}` placeholders of a template, in order of appearance (for the catalog tests).+#[cfg(all(test, feature = "translations"))] pub fn placeholders(s: &str) -> Vec<&str> { let mut out = Vec::new(); let mut rest = s;@@ -1,3 +1,6 @@+// The catalog tests read the translations, which are compiled in with the `translations` feature.+#![cfg(feature = "translations")]+ use super::catalog::{Catalog, parse_entries, placeholders}; use super::*; new file mode 100644@@ -0,0 +1,129 @@+//! The languages the interface offers, whichever language features (`lang-<code>`, all of them with+//! `translations`) the build turns on: a left-out language through a registry of the tests' own,+//! so the default test run covers it, and each catalog's feature in the manifest.++use super::*;++/// A registry with Japanese compiled in and Korean left out (its catalog empty, as its feature off+/// leaves it).+static JA_NOT_KO: [LangInfo; 3] = [+ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },+ LangInfo { code: "ja", name: "日本語", source: "# catalog", plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },+ LangInfo { code: "ko", name: "한국어", source: "", plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },+];++/// A language left out of the build is not offered, not found by its code or a locale, and+/// claims no complete menus; a system language list skips it for the next one compiled in, and+/// with none, English. English and a compiled-in language are offered.+#[test]+fn a_left_out_language_is_neither_offered_nor_found() {+ let ja = Lang(&JA_NOT_KO[1]);+ let ko = Lang(&JA_NOT_KO[2]);+ assert_eq!(all_in(&JA_NOT_KO).collect::<Vec<_>>(), [Lang::EN, ja]);+ assert_eq!(from_code_in(&JA_NOT_KO, "JA"), Some(ja));+ assert_eq!(from_code_in(&JA_NOT_KO, "ko"), None);+ assert_eq!(lang_from_tag_in(&JA_NOT_KO, "ja_JP.UTF-8"), Some(ja));+ assert_eq!(lang_from_tag_in(&JA_NOT_KO, "ko_KR.UTF-8"), None);+ assert_eq!(preferred_in(&JA_NOT_KO, &["ko-KR", "ja-JP", "en-US"]), ja);+ assert_eq!(preferred_in(&JA_NOT_KO, &["ko-KR"]), Lang::EN);+ assert!(ja.complete_menus() && !ko.complete_menus());+}++/// English is always offered, and with `translations` (the apps' default) every registered+/// language is.+#[test]+fn english_always_and_with_translations_every_language_is_offered() {+ assert_eq!(Lang::from_code("en"), Some(Lang::EN));+ if cfg!(feature = "translations") {+ assert_eq!(Lang::all().count(), LANGUAGES.len());+ assert!(Lang::from_code("ja").is_some_and(Lang::complete_menus));+ }+}++/// A catalog whose feature is off is empty, and one whose feature is on is its file: `lang-left-out`+/// is a feature no build has, and `translations` turns on `lang-el`.+#[test]+#[allow(unexpected_cfgs)]+fn a_catalog_is_its_file_only_with_its_feature() {+ assert_eq!(catalog_tsv!("el.tsv", "lang-left-out"), "");+ if cfg!(feature = "lang-el") {+ assert_eq!(catalog_tsv!("el.tsv", "lang-el"), include_str!("el.tsv"));+ }+}++/// Whether each language's feature is on in this build, in the registry's order. `cfg!` takes a+/// literal feature name, so each language has its row here; the test below checks the list+/// against the registry.+const FEATURE_ON: [(&str, bool); 24] = [+ ("el", cfg!(feature = "lang-el")),+ ("ja", cfg!(feature = "lang-ja")),+ ("ko", cfg!(feature = "lang-ko")),+ ("vi", cfg!(feature = "lang-vi")),+ ("cs", cfg!(feature = "lang-cs")),+ ("de", cfg!(feature = "lang-de")),+ ("es", cfg!(feature = "lang-es")),+ ("fr", cfg!(feature = "lang-fr")),+ ("it", cfg!(feature = "lang-it")),+ ("ka", cfg!(feature = "lang-ka")),+ ("ru", cfg!(feature = "lang-ru")),+ ("uk", cfg!(feature = "lang-uk")),+ ("bg", cfg!(feature = "lang-bg")),+ ("hu", cfg!(feature = "lang-hu")),+ ("pl", cfg!(feature = "lang-pl")),+ ("sv", cfg!(feature = "lang-sv")),+ ("pt-br", cfg!(feature = "lang-pt-br")),+ ("zh-hant", cfg!(feature = "lang-zh-hant")),+ ("zh-hans", cfg!(feature = "lang-zh-hans")),+ ("nl", cfg!(feature = "lang-nl")),+ ("da", cfg!(feature = "lang-da")),+ ("id", cfg!(feature = "lang-id")),+ ("fi", cfg!(feature = "lang-fi")),+ ("tr", cfg!(feature = "lang-tr")),+];++/// Each catalog is compiled in by the feature named after its file, the row that reads it has the+/// file's code and every catalog file has text, each row's catalog is its file exactly when its+/// feature is on (and empty otherwise), and+/// the manifest declares exactly one `lang-<code>` feature per language, every one of them listed in+/// `translations`. So no build can offer a language under another's feature, or leave one out of+/// `translations`. (A feature the manifest lacks is also an `unexpected_cfgs` warning, which clippy+/// refuses.)+#[test]+fn each_catalog_has_its_own_feature_and_translations_lists_every_one() {+ let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/i18n");+ let module = std::fs::read_to_string(format!("{dir}.rs")).unwrap();+ let mut files = Vec::new();+ for call in module.split("catalog_tsv!(").skip(1) {+ let args: Vec<&str> = call.split(')').next().unwrap().split(',').map(|a| a.trim().trim_matches('"')).collect();+ let [file, feature] = args[..] else { panic!("catalog_tsv!({call:.40}…) takes a file and a feature") };+ if file == "i18n/xx.tsv" {+ continue; // "Adding a language", in the module's documentation+ }+ let code = file.strip_prefix("i18n/").and_then(|f| f.strip_suffix(".tsv")).unwrap();+ assert_eq!(feature, format!("lang-{code}"), "{file}'s feature");+ files.push(code);+ }+ let registered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).filter(|c| *c != "en").collect();+ assert_eq!(files, registered, "one catalog_tsv! per language, in the table's order");+ for code in ®istered {+ let text = std::fs::read_to_string(format!("{dir}/{code}.tsv")).unwrap();+ assert!(!text.is_empty(), "{code}.tsv is empty");+ let row = LANGUAGES.iter().find(|l| l.code == *code).unwrap();+ let on = FEATURE_ON.iter().find(|(c, _)| c == code).is_some_and(|(_, on)| *on);+ assert_eq!(row.source, if on { text.as_str() } else { "" }, "{code}: its own catalog with lang-{code}, else none");+ }+ let listed: Vec<&str> = FEATURE_ON.iter().map(|(c, _)| *c).collect();+ assert_eq!(listed, registered, "FEATURE_ON lists every language, in the table's order");++ let manifest: toml::Table = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")).unwrap().parse().unwrap();+ let features = manifest["features"].as_table().unwrap();+ let declared: std::collections::BTreeSet<&str> = features.keys().filter_map(|k| k.strip_prefix("lang-")).collect();+ let wanted: std::collections::BTreeSet<&str> = registered.iter().copied().collect();+ assert_eq!(declared, wanted, "one lang-<code> feature per language, and none without one");+ for code in ®istered {+ assert_eq!(features[&format!("lang-{code}")].as_array().map(Vec::len), Some(0), "lang-{code} turns on nothing else");+ }+ let translations: std::collections::BTreeSet<&str> =+ features["translations"].as_array().unwrap().iter().filter_map(|f| f.as_str()?.strip_prefix("lang-")).collect();+ assert_eq!(translations, wanted, "translations turns on every language's feature");+}@@ -65,7 +65,8 @@ pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_ pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut}; pub(crate) use invoke::graph_dialog; pub use invoke::{click_target, invoke, invoke_from_system_menu};-#[cfg(test)]+// Used only by the tests that read catalogs other than English.+#[cfg(all(test, feature = "translations"))] pub(crate) use labels::lists_plugin; #[cfg(test)] use labels::shows_a_name;@@ -176,6 +176,8 @@ fn last_effect_dialog_and_view_toggles() { /// Edit › Undo and Edit › Redo name the step in the UI language as the History panel lists it /// (`panels::history::step_name`), including a step named after an effect whose catalog holds /// only the effect's menu row with an ellipsis, as Japanese does for Feather.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn undo_and_redo_name_the_step_as_the_history_panel_does() { let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");@@ -371,6 +373,8 @@ fn app_with_a_rectangle() -> VectorcraftApp { /// Edit › Undo and Edit › Redo name a step named after a style as the History panel lists it: /// the template's translation with the style's name in it.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn undo_and_redo_name_a_style_step_around_the_style_name() { let mut app = app_with_a_rectangle();@@ -142,8 +142,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests { use super::*;+ #[cfg(feature = "translations")] use crate::i18n::Lang; + // Reads catalogs other than English.+ #[cfg(feature = "translations")] #[test] fn entries_are_built_once_per_language_and_match_english_shown_text_and_ids() { let ctx = egui::Context::default();@@ -257,6 +257,8 @@ fn the_effect_editor_picks_choices_their_own_dialogs_list() { /// Those choices read as their dialogs show them in the UI language: in French, the channel's /// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d" /// row does, not the plain row. Rasterize names the document's color mode.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn the_effect_editor_shows_those_choices_as_their_dialogs_do() { let fr = crate::i18n::Lang::from_code("fr").unwrap();@@ -132,6 +132,8 @@ fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() { /// A variable font gets the Variable Font button, which opens one slider per axis named as the /// font names it; a drag along one sets that axis of the selected type in one undo step.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn variable_fonts_get_a_slider_per_axis() { use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};@@ -118,6 +118,8 @@ mod tests { /// The step names the panel paints, top to bottom. The SVG loaders are installed so each row's /// icon draws as an image instead of egui's ⚠ placeholder.+ // Used only by the tests that read catalogs other than English.+ #[cfg(feature = "translations")] fn listed(app: &mut VectorcraftApp) -> Vec<String> { let ctx = egui::Context::default(); egui_extras::install_image_loaders(&ctx);@@ -136,6 +138,8 @@ mod tests { /// the document holds it, also when the name looks like a template, like a step with a row of /// its own (`Apply Brush`) or like another template's name; the Normal style's name is /// translated as the Character Styles panel shows it. Undone steps keep their names.+ // Reads catalogs other than English.+ #[cfg(feature = "translations")] #[test] fn steps_named_after_a_style_list_the_template_around_the_name() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());@@ -176,6 +180,8 @@ mod tests { /// A step named after an effect whose catalog row has only the menu label with its ellipsis /// takes that row's translation without the ellipsis; a name with no row has none.+ // Reads catalogs other than English.+ #[cfg(feature = "translations")] #[test] fn effect_steps_read_the_menu_row() { let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");@@ -59,10 +59,13 @@ mod thumbs; use controls::{bottom_bar, filter_button}; pub use controls::{expand, menu, set_filter};+// Used only by the tests that read catalogs other than English.+#[cfg(all(test, feature = "translations"))]+use model::painted_name; pub(crate) use model::shown_name; pub use model::{Filter, ObjectKind, RowState}; #[cfg(test)]-use model::{Row, list_rows, painted_name};+use model::{Row, list_rows}; use model::{push_rows, rows_of_doc}; use rows::{drag_ghost, key, rename_id, resolve_click, row}; #[cfg(test)]@@ -12,6 +12,8 @@ fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { /// A generated `<Kind>` name is translated where painted; an unnamed text object's text never /// is, even when it reads like one.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn only_generated_names_are_translated() { let zh = crate::i18n::Lang::from_code("zh-hant").unwrap();@@ -474,6 +474,8 @@ mod tests { /// Load Variable Library's warning about variables that aren't text shows in the status bar, /// translated in every language but English.+ // Reads catalogs other than English.+ #[cfg(feature = "translations")] #[test] fn the_variable_library_warning_shows_in_the_status_bar() { use crate::i18n::{Lang, message};@@ -354,7 +354,7 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { }); } PrefKind::Text if sp.key == "interfaceLanguage" => {- // `auto` plus every registered language, by its own name.+ // `auto` plus every language compiled in, by its own name. labeled(ui, sp.label, |ui| { let cur = v.as_str().unwrap_or("auto"); let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect();@@ -126,6 +126,8 @@ fn settings_lists_every_preferences_page() { /// Language ▸: Automatic, then each language in its own name (in any UI language), the /// preference checked; Appearance ▸: the UI brightness choices, the current one checked.+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn language_and_appearance_are_in_the_app_menu() { let mut app = app(false);@@ -399,6 +399,8 @@ fn double_clicking_a_selection_tool_opens_the_move_dialog() { /// #1616: in every language a flyout is wide enough that no tool's name runs into its shortcut /// (the Spanish Live Paint names need more than the 250 pt the English ones fit in).+// Reads catalogs other than English.+#[cfg(feature = "translations")] #[test] fn a_flyout_is_as_wide_as_its_longest_translated_row() { let ctx = egui::Context::default();@@ -1,5 +1,6 @@ # Development +> **Last updated:** 2026-10-24 · **Change:** choosing the translations (a `lang-<code>` feature per language). > **Last updated:** 2026-10-25 · **Change:** Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30). > **Last updated:** 2026-10-23 · **Change:** Tests: cargo xtask test, one integration-test binary per crate, the dev profile's debug info. > **Last updated:** 2026-10-21 · **Change:** the interface faces as vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels itself.@@ -198,10 +199,11 @@ context's font definitions, so the embedder's own fonts are added after it. vectorcraft-text`, and the same for `vectorcraft-mcp`), and with them do; a test of either can't see that configuration, since their dev-dependencies turn the fonts on. - `cargo test -p vectorcraft-text --no-default-features --test without_bundled_fonts` checks the- configuration without them, and `cargo test -p vectorcraft-ui-egui --no-default-features --test- install_fonts` that the panels without their interface faces still bind every family; the same- step lints `vectorcraft-ui-egui` without its default features. Of the crate's other tests, those- about the interface faces need them.+ configuration without them, and `cargo nextest run -p vectorcraft-ui-egui --no-default-features`+ (`cargo test` without nextest, as the test step runs) runs the panels' tests without their+ interface faces or any translation, among them `install_fonts` (the panels still bind every+ family); the same step lints `vectorcraft-ui-egui` without its default features. The crate's tests about the interface faces need them, and are left out of+ that run. ### Arabic fonts and the web build @@ -257,11 +259,14 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE code; `auto` follows the system locale: `VECTORCRAFT_LOCALE`, then `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`, the macOS preferred languages, the Windows user locale; on the web, `?lang=<code>` in the address, then the browser's `navigator.languages`). The Preferences dialog previews the chosen language before OK.-- To add a language: add `<code>.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog, plural- rule). The Language menu, the dropdown, locale matching and the catalog tests (well-formed, no duplicates,- placeholders and ellipses agree, command ids exist, every row of a partial catalog is a string the UI- shows, the UI fonts have every glyph, no Simplified characters in `zh-hant`) pick it up. Set `complete_menus`- once the menus and `tl!` literals are translated; the untranslated report then lists what the catalog lacks+- To add a language: add `<code>.tsv`, one row in `i18n::LANGUAGES` (code, native name, catalog as+ `catalog_tsv!("<code>.tsv", "lang-<code>")`, plural rule) and the feature `lang-<code>` in+ `crates/ui-egui/Cargo.toml`, listed in `translations` (and in `FEATURE_ON`,+ `i18n/tests_compiled_in.rs`). The Language menu, the dropdown, locale matching and the catalog+ tests (well-formed, no duplicates, placeholders and ellipses agree, command ids exist, every row+ of a partial catalog is a string the UI shows, the UI fonts have every glyph, no Simplified+ characters in `zh-hant`) pick it up. Set `complete_menus` once the menus and `tl!` literals are+ translated; the untranslated report then lists what the catalog lacks (`VECTORCRAFT_I18N_DUMP=strings.txt cargo test -p vectorcraft-ui-egui dump_source_strings` lists every English string). - A missing translation never fails a test or a build: the interface shows the English text until a translator@@ -293,6 +298,37 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE system font; the glyph test checks Latin and Cyrillic catalogs always and the CJK ones only with craft-fonts. A Traditional Chinese UI font is still to be added to craft-fonts for the web build. +### Choosing the translations (embedders)++The interface's translations, one catalog per language in `crates/ui-egui/src/i18n/*.tsv`, are+compiled into `vectorcraft-ui-egui` one feature per language, named after the catalog's file:+`lang-de`, `lang-ja`, `lang-pt-br`, `lang-zh-hant` and so on. The `translations` feature turns on+every one (about 6.6 MB of the binary), and is on by default, so the apps have every language. An+embedder that wants only some of them depends on `vectorcraft-ui-egui` with+`default-features = false` and lists those, for example `features = ["lang-de", "lang-fr"]`; with+none, the interface is English only and ships no catalog. All the catalogs are about 6.6 MB of+text, 2.0 MB with `gzip -9` and 1.1 MB with `brotli -q 11` (2026-10-21;+`cat crates/ui-egui/src/i18n/*.tsv | gzip -9 | wc -c`, and the same with `brotli -q 11 -c`).++A language whose feature is off is left out: `Lang::all` doesn't offer it, `Lang::from_code` doesn't+know its code, and it claims no complete menus. A system locale for it is skipped: the next language+in the system's list that is compiled in is taken, else English. A preference saved by a build that+had it, such as `interfaceLanguage: "fr"`, is read as an unknown code is: it follows the system+locale, as Automatic does (Preferences shows the code as it is), and `app.language` refuses the+code. Preferences and the Language menu offer Automatic, English and the languages compiled in.++- The catalog tests (`i18n/tests.rs`) and the tests that read another language's catalog run with+ every language (`#[cfg(feature = "translations")]`); every other test of the crate also passes+ English only.+- `i18n/tests_compiled_in.rs` checks what a left-out language does with a registry of its own, in+ which one language is compiled in and another is not, so the default test run covers it; that a+ catalog is empty without its feature (one no build has) and its file with it; and that each+ `catalog_tsv!` names the feature after its own file, each row reads its own file and+ `translations` lists every language's feature.+- `cargo xtask ci`'s `no-bundled-fonts` step lints and runs the crate's tests with no default+ feature, so with no language feature, as an English-only embedder builds it: a new test that+ reads another language's catalog without that `cfg` fails there.+ ## Cursors `crates/tools/src/cursors.toml` lists every `Cursor` value: when it shows, in plain behaviour terms, and where its hotspot is, so whoever draws the cursors knows what each one is for without reading the tools (CLAUDE.md › Adding a cursor). `cargo xtask cursors` (part of `cargo xtask ci`) warns about a `Cursor` value with no entry, an entry with no `Cursor` value, a repeated id or a missing field; it never fails the build. A cursor not drawn yet borrows another's look (`Cursor::stand_in`).@@ -493,6 +529,7 @@ Scores and hour estimates are unchanged. | Date | Change | Summary | |---|---|---| | 2026-10-25 | minor | Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30); behavior unchanged. |+| 2026-10-24 | minor | Document the language features of vectorcraft-ui-egui (Localisation › Choosing the translations). | | 2026-10-23 | minor | Tests: `cargo xtask test` (nextest, then doctests), one integration-test binary per crate and the files that keep their own, the dev profile's debug info. | | 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels without `VectorcraftApp::logic`. | | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |@@ -226,16 +226,25 @@ fn has_nextest() -> bool { /// The workspace's unit and integration tests, without examples (clippy still checks those). /// nextest runs each test in its own process, in parallel across all test binaries. fn cmd_test() -> Result<(), String> {+ run_tests(&["--workspace"])+}++/// The unit and integration tests of `scope` (`--workspace`, or `-p <crate>` and its features),+/// without examples, run as [`cmd_test`] runs them: by cargo nextest when installed, each test in+/// its own process, else by cargo test.+fn run_tests(scope: &[&str]) -> Result<(), String> { const TARGETS: [&str; 3] = ["--lib", "--bins", "--tests"]; let mut c = cargo();- if has_nextest() {- c.args(["nextest", "run", "--workspace"]).args(TARGETS);- run(c, "cargo nextest run --workspace --lib --bins --tests")+ let runner = if has_nextest() {+ c.args(["nextest", "run"]);+ "cargo nextest run" } else { eprintln!("note: cargo-nextest is not installed; running cargo test (cargo install cargo-nextest for faster runs)");- c.args(["test", "--workspace"]).args(TARGETS);- run(c, "cargo test --workspace --lib --bins --tests")- }+ c.arg("test");+ "cargo test"+ };+ c.args(scope).args(TARGETS);+ run(c, &format!("{runner} {} {}", scope.join(" "), TARGETS.join(" "))) } /// The doctests, which nextest doesn't run.@@ -278,14 +287,16 @@ fn cmd_ci() -> Result<(), String> { // The egui panels and the MCP server as such an embedder builds them: nothing turns // the fonts back on. libraries_without_bundled_fonts()?;- // The panels without their interface faces lint clean and still bind every family- // they draw in.+ // The panels without their interface faces or any language (English only), as+ // such an embedder builds them: they lint clean, and their tests pass, among them+ // install_fonts (every family they draw in is still bound). They run as the test+ // step runs them: their tests share the process-wide font database, which some of+ // them add test fonts to, so one process per test under nextest. What a left-out+ // language does is tested in the test step (i18n::tests_compiled_in). let mut c = cargo(); c.args(["clippy", "-p", "vectorcraft-ui-egui", "--no-default-features", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy -p vectorcraft-ui-egui --no-default-features --all-targets -- -D warnings")?;- let mut c = cargo();- c.args(["test", "-p", "vectorcraft-ui-egui", "--no-default-features", "--test", "install_fonts"]);- run(c, "cargo test -p vectorcraft-ui-egui --no-default-features --test install_fonts")+ run_tests(&["-p", "vectorcraft-ui-egui", "--no-default-features"]) }), ), ("pdf-deps", Box::new(|| pdf_deps::run(&root()))),