Add Chinese language support to preferences (#684)

Squash of PR #684 by @mayloglog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mayloglogandClaude Opus 5.5 authored and GitHub committed 2026-10-12 03:28:17 -04:00
1 parent 0675648de8
commit 4bb3acb913
10 files changed
+1127 -15

No files matched your search

+1
View File
@@ -63,3 +63,4 @@ Generated-in-code assets are original and have no file to list:
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
+1 -1
View File
@@ -111,7 +111,7 @@ numbers):
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский) and French (Français), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français) and Simplified Chinese (简体中文, with an installed CJK font), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
## Quick start
+11 -2
View File
@@ -29,10 +29,12 @@ pub enum Preference {
Russian,
#[serde(rename = "fr")]
French,
#[serde(rename = "zh")]
Chinese,
}
impl Preference {
pub const ALL: [Self; 9] = [
pub const ALL: [Self; 10] = [
Self::Auto,
Self::English,
Self::Ukrainian,
@@ -42,6 +44,7 @@ impl Preference {
Self::Czech,
Self::Russian,
Self::French,
Self::Chinese,
];
/// The codes a preference can be given as, for messages ("auto, en or uk").
@@ -64,6 +67,7 @@ impl Preference {
Self::Czech => "cs",
Self::Russian => "ru",
Self::French => "fr",
Self::Chinese => "zh",
}
}
@@ -78,6 +82,7 @@ impl Preference {
Self::Czech => "Čeština",
Self::Russian => "Русский",
Self::French => "Français",
Self::Chinese => "简体中文",
}
}
@@ -100,6 +105,7 @@ impl Preference {
Self::Czech => "cs",
Self::Russian => "ru",
Self::French => "fr",
Self::Chinese => "zh",
Self::Auto => locales.iter().find_map(|s| locale_language(s)).unwrap_or("en"),
}
}
@@ -116,7 +122,7 @@ pub struct Language {
catalog: OnceLock<Catalog<'static>>,
}
pub static LANGUAGES: [Language; 8] = [
pub static LANGUAGES: [Language; 9] = [
Language { code: "en", name: "English", source: "", plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
Language {
code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new()
@@ -138,6 +144,9 @@ pub static LANGUAGES: [Language; 8] = [
Language { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_uk, plural_forms: 3, catalog: OnceLock::new() },
// French integer plurals (CLDR `fr`): one (0, 1), other, the Portuguese rule.
Language { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_pt, plural_forms: 2, catalog: OnceLock::new() },
// Chinese has no grammatical plural: both `@plural` forms read the same. Its glyphs come from
// an installed CJK font (`theme::cjk_font`); without one the interface stays English.
Language { code: "zh", name: "简体中文", source: include_str!("zh.tsv"), plural: plural_en, plural_forms: 2, catalog: OnceLock::new() },
];
/// BCP 47 and POSIX locale tags, including `uk-UA`, `uk_UA.UTF-8` and `pt_BR.UTF-8`.
+18 -1
View File
@@ -39,7 +39,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
/// to English for what they don't cover yet; their gaps are reported, not enforced, so adding a
/// command doesn't require a row in every language.
const REQUIRED: &[&str] = &["uk"];
const REPORTED: &[&str] = &["pt-BR", "de", "es", "cs", "ru", "fr"];
const REPORTED: &[&str] = &["pt-BR", "de", "es", "cs", "ru", "fr", "zh"];
fn check(language: &str, what: &str, missing: Vec<String>) {
if REQUIRED.contains(&language) {
@@ -524,3 +524,20 @@ fn russian_and_french_plurals() {
}
});
}
/// Chinese shows only with an installed CJK font (none is bundled); without one the interface
/// stays English instead of drawing boxes, and choosing it says why.
#[test]
fn chinese_needs_an_installed_cjk_font() {
assert_eq!(locale_language("zh-CN"), Some("zh"));
let mut app = CadApp::new(Session::new(), Services::default());
let r = app.run("ui.language", json!({"lang": "zh"})).unwrap();
assert_eq!(r["interfaceLanguage"], "zh");
let font = crate::theme::cjk_font();
assert_eq!(app.language(), if font.is_some() { "zh" } else { "en" });
assert_eq!(r["shown"], app.language());
assert!(crate::theme::can_show("uk") && crate::theme::can_show("en"));
if font.is_none() {
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("No Chinese font")));
}
}
File diff suppressed because it is too large. Load diff
+11 -6
View File
@@ -195,7 +195,8 @@ pub struct CadApp {
/// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]).
#[cfg(not(target_arch = "wasm32"))]
pub frame_cap: Option<gpu::FrameCap>,
styled: bool,
/// Interface fonts installed, and whether with a CJK font (`None` until the first frame).
fonts_cjk: Option<bool>,
/// The theme last installed into egui (`None` until the first frame).
shown_theme: Option<egui::Theme>,
/// Whether the last applied theme was handed to egui as the System preference.
@@ -237,7 +238,7 @@ impl CadApp {
synthetic: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
frame_cap: None,
styled: false,
fonts_cjk: None,
shown_theme: None,
shown_follows_system: false,
frame_ms: 0.0,
@@ -251,8 +252,11 @@ impl CadApp {
}
}
/// The interface language shown: the chosen one, or English when this computer has no font
/// for it (Chinese without an installed CJK font would be boxes).
pub fn language(&self) -> &'static str {
self.ui.interface_language.resolve(&self.system_languages)
let l = self.ui.interface_language.resolve(&self.system_languages);
if theme::can_show(l) { l } else { "en" }
}
/// Draw the canvas on the GPU with the app's wgpu render state (eframe's
@@ -410,9 +414,10 @@ impl CadApp {
if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
if !self.styled {
theme::install_fonts(ctx);
self.styled = true;
let cjk = self.language().starts_with("zh");
if self.fonts_cjk != Some(cjk) {
theme::install_fonts(ctx, cjk);
self.fonts_cjk = Some(cjk);
}
// Resolved every frame, so a System choice follows the OS appearance live (winit and the
// browser report changes as input); the saved choice itself never changes here.
+7 -1
View File
@@ -114,7 +114,13 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
return Some(Err(format!("language must be {}", crate::i18n::Preference::codes())));
};
app.ui.interface_language = preference;
Ok(json!({"interfaceLanguage": preference.code()}))
let shown = app.language();
if shown != preference.resolve(&app.system_languages) {
app.set_status(
"No Chinese font is installed, so the interface stays in English. Install one (Noto Sans CJK, for example) to use Chinese.",
);
}
Ok(json!({"interfaceLanguage": preference.code(), "shown": shown}))
}
"ui.open" | "open" if no_path => {
let picked = app.services.pick_open.as_ref().and_then(|f| f());
+1 -1
View File
@@ -9,7 +9,7 @@
//! | name | value |
//! |---|---|
//! | `theme` | `"system"`, `"light"` or `"dark"` |
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"` or `"fr"` |
//! | `interfaceLanguage` | `"auto"`, `"en"`, `"uk"`, `"pt-BR"`, `"de"`, `"es"`, `"cs"`, `"ru"`, `"fr"` or `"zh"` |
//! | `saveFormat` | `"dxf"` or `"dwg"`: the format Save As suggests for new drawings |
//! | `saveVersion` | `"2000"` … `"2018"`: the version drawings with no file yet are saved in |
//! | `SAVETIME` | minutes between automatic saves, 0–600 (0: off) |
+62 -2
View File
@@ -302,7 +302,7 @@ pub fn mono() -> FontId {
}
/// Load a system UI font at run time when one is installed (not bundled), else keep egui's.
pub fn install_fonts(ctx: &egui::Context) {
pub fn install_fonts(ctx: &egui::Context, cjk: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
let candidates: &[&str] = if cfg!(target_os = "macos") {
@@ -327,10 +327,70 @@ pub fn install_fonts(ctx: &egui::Context) {
break;
}
}
// Chinese: an installed CJK font after the Latin ones, so Latin text keeps its look.
if let Some(bytes) = cjk.then(cjk_font).flatten().and_then(|p| read_capped(p, MAX_UI_FONT_BYTES)) {
fonts.font_data.insert("system-cjk".into(), std::sync::Arc::new(egui::FontData::from_owned(bytes)));
for family in [FontFamily::Proportional, FontFamily::Monospace] {
if let Some(f) = fonts.families.get_mut(&family) {
f.push("system-cjk".into());
}
}
}
ctx.set_fonts(fonts);
}
#[cfg(target_arch = "wasm32")]
let _ = ctx;
let _ = (ctx, cjk);
}
/// Largest interface font file read (CJK collections are 15–60 MB).
#[cfg(not(target_arch = "wasm32"))]
const MAX_UI_FONT_BYTES: u64 = 96 * 1024 * 1024;
#[cfg(not(target_arch = "wasm32"))]
fn read_capped(path: &std::path::Path, max: u64) -> Option<Vec<u8>> {
let len = std::fs::metadata(path).ok()?.len();
if len == 0 || len > max {
return None;
}
std::fs::read(path).ok()
}
/// An installed font with Chinese characters, for the Chinese interface; fonts are never bundled
/// (only looked for where the OS or common packages put them). Checked once.
pub fn cjk_font() -> Option<&'static std::path::Path> {
#[cfg(not(target_arch = "wasm32"))]
{
static FOUND: std::sync::OnceLock<Option<std::path::PathBuf>> = std::sync::OnceLock::new();
FOUND
.get_or_init(|| {
let candidates: &[&str] = if cfg!(target_os = "macos") {
&["/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/STHeiti Light.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc"]
} else if cfg!(windows) {
&["C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\msyh.ttf", "C:\\Windows\\Fonts\\simsun.ttc"]
} else {
&[
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJKsc-Regular.otf",
"/usr/local/share/fonts/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/wqy-microhei/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
]
};
candidates.iter().map(std::path::PathBuf::from).find(|p| p.is_file())
})
.as_deref()
}
#[cfg(target_arch = "wasm32")]
None
}
/// Whether the interface can show `language`: Chinese needs an installed CJK font.
pub fn can_show(language: &str) -> bool {
!language.starts_with("zh") || cjk_font().is_some()
}
/// egui's visuals for `theme`, from its tokens.
+1 -1
View File
@@ -115,7 +115,7 @@ Select a language with `engine.execute` and the UI command `ui.language`:
{"id":1,"method":"engine.execute","params":{"command":"ui.language","params":{"lang":"uk"}}}
```
Supported values are `auto`, `en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru` and `fr`. `ui.set` also accepts
Supported values are `auto`, `en`, `uk`, `pt-BR`, `de`, `es`, `cs`, `ru`, `fr` and `zh` (Chinese needs an installed CJK font; without one the result's `shown` is `en`). `ui.set` also accepts
`{"interfaceLanguage":"uk"}`; `ui.inspect` reports the saved choice in its UI state.
Invalid choices leave the current language unchanged. Menus, command labels and prompt text
returned by the control channel retain their English source text. See [localization.md](localization.md).