mirror of
https://github.com/aphrody-labs/cadcraft.git
synced 2026-10-13 19:26:54 +00:00
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:
1 parent
0675648de8
commit
4bb3acb913
10 files changed
+1127
-15
No files matched your search
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user