feat: add multi-language (i18n) support with Japanese UI

This commit is contained in:
Mithril-3d committed 2026-10-11 12:52:39 +09:00
1 parent fc8b71956c
commit 3afd6ae447
11 files changed
+647 -80

No files matched your search

+56 -17
View File
@@ -46,6 +46,8 @@ pub struct Prefs {
pub angle_decimals: u8,
/// `dark`, `light` or `system` (follow the operating system).
pub theme: String,
/// Interface language (`en`, `ja`).
pub language: String,
/// Interface scale (1 = 100 %).
pub ui_scale: f32,
/// The wheel zooms toward the point under the cursor (else toward the view's centre).
@@ -70,6 +72,7 @@ impl Default for Prefs {
length_decimals: 2,
angle_decimals: 1,
theme: "dark".into(),
language: "en".into(),
ui_scale: 1.0,
zoom_to_cursor: true,
look_at_anim: true,
@@ -157,6 +160,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
match page {
0 => {
p.theme = d.theme;
p.language = d.language;
p.ui_scale = d.ui_scale;
p.default_units = d.default_units;
p.length_decimals = d.length_decimals;
@@ -376,7 +380,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut open = true;
let mut button: Option<&str> = None;
let before = app.preferences.clone();
crate::frame::window(ctx, "Preferences", crate::frame::Width::Wide)
crate::frame::window(ctx, app.tr("Preferences"), crate::frame::Width::Wide)
.id(egui::Id::new("sc_prefs"))
.open(&mut open)
.collapsible(false)
@@ -389,7 +393,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.vertical(|ui| {
ui.set_width(140.0);
for (i, s) in PAGES.iter().enumerate() {
if ui.selectable_label(app.prefs_window.section == i, *s).clicked() {
if ui.selectable_label(app.prefs_window.section == i, app.tr(s)).clicked() {
app.prefs_window.section = i;
app.keymap.read_only = false;
}
@@ -399,24 +403,24 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.vertical(|ui| {
// A fixed width, so the window keeps its size from page to page.
ui.set_width(540.0);
ui.label(RichText::new(PAGES.get(app.prefs_window.section).copied().unwrap_or_default()).strong().size(14.0));
ui.label(RichText::new(app.tr(PAGES.get(app.prefs_window.section).copied().unwrap_or_default())).strong().size(14.0));
ui.add_space(6.0);
page(app, ui, &t);
});
});
ui.separator();
ui.horizontal(|ui| {
if ui.button("Restore Defaults").on_hover_text("Reset the settings on this page").clicked() {
if ui.button(app.tr("Restore Defaults")).on_hover_text(app.tr("Reset the settings on this page")).clicked() {
button = Some("defaults");
}
crate::frame::right_aligned(ui, 700.0, 170.0, |ui| {
if ui.button("OK").clicked() {
if ui.button(app.tr("OK")).clicked() {
button = Some("ok");
}
if ui.button("Cancel").clicked() {
if ui.button(app.tr("Cancel")).clicked() {
button = Some("cancel");
}
if ui.button("Apply").clicked() {
if ui.button(app.tr("Apply")).clicked() {
button = Some("apply");
}
});
@@ -478,10 +482,19 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
egui::Grid::new(("sc_prefs_grid", app.prefs_window.section)).num_columns(2).spacing([16.0, 8.0]).show(ui, |ui| match app.prefs_window.section {
0 => {
ui.label("Theme");
ui.label(app.tr("Language"));
egui::ComboBox::from_id_salt("pref_language")
.selected_text(crate::i18n::LANGUAGES.iter().find(|(id, _)| *id == app.preferences.language).map_or("English", |x| x.1))
.show_ui(ui, |ui| {
for (id, label) in crate::i18n::LANGUAGES {
ui.selectable_value(&mut app.preferences.language, id.to_string(), label);
}
});
ui.end_row();
ui.label(app.tr("Theme"));
ui.horizontal(|ui| {
for (id, label) in THEMES {
if ui.radio(app.preferences.theme == id, label).clicked() {
if ui.radio(app.preferences.theme == id, app.tr(label)).clicked() {
app.preferences.theme = id.to_string();
if id != "system" {
app.ui.dark = id == "dark";
@@ -490,33 +503,34 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
ui.end_row();
ui.label("UI scale");
ui.label(app.tr("UI scale"));
ui.add(egui::Slider::new(&mut app.preferences.ui_scale, 0.75..=2.0).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0)));
ui.end_row();
ui.label("Units of new designs");
ui.label(app.tr("Units of new designs"));
egui::ComboBox::from_id_salt("pref_units").selected_text(app.preferences.default_units.clone()).show_ui(ui, |ui| {
for u in UNITS {
ui.selectable_value(&mut app.preferences.default_units, u.to_string(), u);
}
});
ui.end_row();
ui.label("Autosave every");
ui.label(app.tr("Autosave every"));
if ui.add(egui::DragValue::new(&mut app.autosave_minutes).range(0.0..=120.0).speed(0.5).suffix(" min")).changed() {
set_autosave(app);
}
ui.end_row();
note(ui, "0 turns it off");
ui.label("Length decimals");
ui.label(app.tr("Length decimals"));
ui.add(egui::DragValue::new(&mut app.preferences.length_decimals).range(0..=6));
ui.end_row();
ui.label("Angle decimals");
ui.label(app.tr("Angle decimals"));
ui.add(egui::DragValue::new(&mut app.preferences.angle_decimals).range(0..=6));
ui.end_row();
ui.label("Agent cursor");
ui.label(app.tr("Agent cursor"));
let speed_label = app.tr("Agent cursor speed");
let s = &mut app.ui.agent_cursor;
ui.checkbox(&mut s.show, "Show the pointer an AI agent moves (#35)");
ui.end_row();
ui.label("Agent cursor speed");
ui.label(speed_label);
ui.add_enabled_ui(s.show, |ui| {
crate::agent_cursor::speed_slider(ui, &mut s.speed);
});
@@ -693,6 +707,9 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
if !THEMES.iter().any(|(id, _)| *id == new.theme) || !["z", "y"].contains(&new.up_axis.as_str()) {
return Err("`theme` must be dark, light or system and `up_axis` z or y".into());
}
if !crate::i18n::LANGUAGES.iter().any(|(id, _)| *id == new.language) {
return Err("`language` must be en or ja".into());
}
if !(0.5..=2.5).contains(&new.ui_scale) || !(0.25..=4.0).contains(&new.look_at_speed) {
return Err("`ui_scale` must be 0.5…2.5 and `look_at_speed` 0.25…4".into());
}
@@ -778,11 +795,33 @@ mod tests {
// File and Help › Keyboard Shortcuts open the same dialog at its page.
app.keymap.open = true;
let ctx = egui::Context::default();
let _ = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
let mut out = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx()));
out.textures_delta.clear();
assert!(app.prefs_window.open && app.prefs_window.section == PAGE_SHORTCUTS && !app.keymap.open);
assert!(control(&mut app, &json!({"open": "Plugins"})).is_err());
assert!(set(&mut app, &json!({"up_axis": "x"})).is_err());
assert!(set(&mut app, &json!({"theme": "sepia"})).is_err());
assert!(set(&mut app, &json!({"language": "fr"})).is_err());
}
#[test]
fn language_preference_switches_and_persists() {
let mut app = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
assert_eq!(app.preferences.language, "en");
assert_eq!(app.tr("Preferences"), "Preferences");
assert_eq!(app.tr("Extrude"), "Extrude");
// Switch language to Japanese
set(&mut app, &json!({"language": "ja"})).unwrap();
assert_eq!(app.preferences.language, "ja");
assert_eq!(app.tr("Preferences"), "環境設定");
assert_eq!(app.tr("Extrude"), "押し出し");
// Preferences round-trip
let mut other = SolveApp::new(solvecraft_engine::Session::default(), Default::default());
other.load_prefs(&app.prefs());
assert_eq!(other.preferences.language, "ja");
assert_eq!(other.tr("Preferences"), "環境設定");
}
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.