Merge pull request #112 from Mithril-3d/feat/i18n-support

feat: add extensible offline localization with Japanese UI
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-11 08:01:30 -04:00
commit 442c1f134b
18 files changed
+1213 -84

No files matched your search

Generated
+1
View File
@@ -3242,6 +3242,7 @@ dependencies = [
"serde",
"serde_json",
"solvecraft-engine",
"ttf-parser",
"web-time",
]
+1
View File
@@ -169,6 +169,7 @@ fn main() -> eframe::Result {
"SolveCraft",
options,
Box::new(move |cc| {
solvecraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services());
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
+1
View File
@@ -17,6 +17,7 @@ serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
image = { workspace = true, features = ["jpeg"] }
ttf-parser = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
+5 -4
View File
@@ -367,7 +367,8 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
x += 21.0;
}
let col = row.color.unwrap_or(if row.dim { t.text_dim } else { t.text });
let galley = ui.painter().layout_no_wrap(row.label.to_string(), FontId::proportional(12.5), col);
let label_display = crate::i18n::tr_dyn(row.label);
let galley = ui.painter().layout_no_wrap(label_display, FontId::proportional(12.5), col);
let lw = galley.size().x;
ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, col);
let mut bx = (x + lw + 6.0).min(r.right() - 16.0);
@@ -378,9 +379,9 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
}
crate::scenario::publish_handle(&format!("row:{}", row.label), r.center());
let tip = match hover_on {
1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }),
2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }),
3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }),
1 => Some(if row.fold == Some(true) { crate::i18n::tr("ui.collapse") } else { crate::i18n::tr("ui.expand") }),
2 => Some(if row.eye == Some(true) { crate::i18n::tr("ui.hide") } else { crate::i18n::tr("ui.show") }),
3 => Some(if row.radio == Some(true) { crate::i18n::tr("ui.active_component") } else { crate::i18n::tr("ui.activate") }),
_ => None,
};
let resp = match tip {
+8 -5
View File
@@ -1385,7 +1385,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
painter.circle_filled(c, 3.0, t.text_dim);
}
for (k, item) in radial.iter().enumerate() {
let galley = painter.layout_no_wrap(item.label.clone(), font.clone(), if item.enabled { t.text } else { t.text_dim });
let display_label = crate::i18n::tr_label(&item.label);
let galley = painter.layout_no_wrap(display_label.to_string(), font.clone(), if item.enabled { t.text } else { t.text_dim });
let size = vec2(galley.size().x + 40.0, 28.0);
let rr = slot_rect(c, k, size);
let resp = ui.interact(rr, ui.id().with(("radial", k)), Sense::click());
@@ -1407,7 +1408,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
picked = Some(item.clone());
}
if item.enabled {
resp.on_hover_text(item.shortcut.clone());
resp.on_hover_text(crate::i18n::tr_label(&item.shortcut).to_string());
}
}
});
@@ -1501,7 +1502,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
return None;
}
if item.is_heading() {
let text = ui.painter().layout(item.label.to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
let text = ui.painter().layout(crate::i18n::tr_label(&item.label).to_uppercase(), FontId::proportional(10.5), t.text_dim, w - 16.0);
let (r, _) = ui.allocate_exact_size(vec2(w, (text.size().y + 8.0).max(20.0)), Sense::hover());
ui.painter().galley(pos2(r.left() + 8.0, r.top() + 4.0), text, t.text_dim);
return None;
@@ -1522,9 +1523,11 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
);
}
let text = if item.enabled { t.text } else { t.text_dim.gamma_multiply(0.75) };
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &item.label, FontId::proportional(13.0), text);
let display_label = crate::i18n::tr_label(&item.label);
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, display_label, FontId::proportional(13.0), text);
if !item.shortcut.is_empty() {
ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, &item.shortcut, FontId::proportional(11.5), t.text_dim);
let display_shortcut = crate::i18n::tr_label(&item.shortcut);
ui.painter().text(pos2(r.right() - 10.0, r.center().y), Align2::RIGHT_CENTER, display_shortcut, FontId::proportional(11.5), t.text_dim);
}
(item.enabled && resp.clicked()).then(|| item.clone())
}
+34 -13
View File
@@ -775,9 +775,10 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
let row = ROW.with(|r| r.borrow().clone());
let r = egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
let selected = labels.get(*sel).copied().map(tr).unwrap_or("");
let r = egui::ComboBox::from_id_salt(id).selected_text(selected).width(FIELD_W).show_ui(ui, |ui| {
for (i, l) in labels.iter().enumerate() {
let item = ui.selectable_value(sel, i, *l);
let item = ui.selectable_value(sel, i, tr(l));
crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center());
}
});
@@ -789,6 +790,10 @@ thread_local! {
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
fn tr(text: &str) -> &str {
crate::i18n::tr_label(text)
}
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
fn publish_row(r: &egui::Response) {
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
@@ -878,11 +883,12 @@ const LABEL_MAX_W: f32 = 150.0;
/// A row label in the dialog's label column (the grid sizes the column to its widest label).
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
ROW.with(|r| *r.borrow_mut() = text.to_string());
let full = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let display_text = tr(text);
let full = egui::WidgetText::from(display_text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let w = full.clamp(LABEL_W, LABEL_MAX_W);
ui.allocate_ui_with_layout(vec2(w, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.set_width(w);
ui.add(egui::Label::new(text).wrap());
ui.add(egui::Label::new(display_text).wrap());
});
}
@@ -1057,9 +1063,14 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
let filled = !inp.items.is_empty() || custom_axis;
let text = if filled {
RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE)
RichText::new(if custom_axis {
crate::i18n::tr("dialog.custom_axis").into()
} else {
crate::i18n::format("dialog.selected_count", &[("count", &inp.items.len().to_string())])
})
.color(Color32::WHITE)
} else {
RichText::new("↖ Select").color(t.text)
RichText::new(crate::i18n::tr("dialog.select")).color(t.text)
};
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.dialog_border.gamma_multiply(1.6)) };
// Not in the Tab order: Tab moves between values.
@@ -1255,6 +1266,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
/// the view cube, sized to its content.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(mut d) = app.dialog.take() else { return };
let _language = crate::i18n::scope(&app.preferences.language);
auto_operation(app, &mut d);
update_measure(app, &mut d);
update_fill_cells(app, &mut d);
@@ -1267,7 +1279,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut cancel = false;
let mut enter = false;
let wide = d.is_wide();
let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() };
let raw_title = title(&d.kind);
let heading = if d.editing.is_some() { crate::i18n::format("dialog.edit_title", &[("title", tr(raw_title))]) } else { tr(raw_title).to_string() };
let frame = egui::Frame::window(&ctx.global_style())
.fill(t.dialog_bg)
.stroke(Stroke::new(1.0, t.dialog_border))
@@ -1917,21 +1930,29 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
egui::Spinner::new().size(14.0).paint_at(ui, sr);
sresp.on_hover_text("Updating the preview");
}
let buttons = if has_ok { 64.0 * 2.0 + ui.spacing().item_spacing.x } else { 64.0 };
let raw_label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let label = tr(raw_label);
let raw_close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
let close = tr(raw_close);
let button_width = |text: &str| {
let galley = egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Button);
(galley.size().x + 2.0 * ui.spacing().button_padding.x).max(64.0)
};
let (ok_width, close_width) = (button_width(label), button_width(close));
let buttons = close_width + if has_ok { ok_width + ui.spacing().item_spacing.x } else { 0.0 };
crate::frame::right_aligned(ui, total, buttons, |ui| {
if has_ok {
let label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
.min_size(vec2(64.0, 24.0));
.wrap_mode(egui::TextWrapMode::Extend)
.min_size(vec2(ok_width, 24.0));
let r = ui.add_enabled(valid, b);
crate::scenario::publish_handle(&format!("dialog:{label}"), r.rect.center());
crate::scenario::publish_handle(&format!("dialog:{raw_label}"), r.rect.center());
if r.clicked() {
ok = true;
}
}
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
if ui.add(egui::Button::new(close).min_size(vec2(64.0, 24.0))).clicked() {
if ui.add(egui::Button::new(close).wrap_mode(egui::TextWrapMode::Extend).min_size(vec2(close_width, 24.0))).clicked() {
cancel = true;
}
});
+18 -7
View File
@@ -311,7 +311,14 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
t.app_bar
},
);
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
let home_text = app.tr("ui.home");
ui.painter().text(
home.center(),
Align2::CENTER_CENTER,
home_text,
FontId::proportional(13.0),
if app.home.open { t.text } else { t.app_bar_text },
);
crate::scenario::publish_handle("tab:home", home.center());
if response.clicked() {
app.home.show(app.session.revision);
@@ -346,7 +353,8 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
};
ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill);
let ink = if active { t.text } else { t.app_bar_text };
let label = format!("{name}{}", if *dirty { " •" } else { "" });
let name_display = if name == "Untitled" { app.tr("ui.untitled") } else { name };
let label = format!("{name_display}{}", if *dirty { " •" } else { "" });
let g = ui.painter().layout_no_wrap(label, FontId::proportional(13.0), ink);
let text_w = (tr.width() - 30.0).max(10.0);
let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height()));
@@ -448,26 +456,29 @@ pub enum Answer {
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
let _language = crate::i18n::scope(&app.preferences.language);
quit_step(app);
let Some(i) = app.docs.closing else { return };
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
let name_display = if name == "Untitled" { crate::i18n::tr("ui.untitled") } else { &name };
let mut answer: Option<Answer> = None;
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
let title = crate::i18n::format("document.save_changes", &[("name", name_display)]);
crate::frame::window(ctx, title, crate::frame::Width::Normal)
.id(egui::Id::new("sc_close_prompt"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
.show(ctx, |ui| {
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
ui.label(crate::i18n::format("document.unsaved_changes", &[("name", name_display)]));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
if ui.button(crate::i18n::tr("ui.save")).clicked() {
answer = Some(Answer::Save);
}
if ui.button("Don't Save").clicked() {
if ui.button(crate::i18n::tr("ui.don_t_save")).clicked() {
answer = Some(Answer::DontSave);
}
if ui.button("Cancel").clicked() {
if ui.button(crate::i18n::tr("ui.cancel")).clicked() {
answer = Some(Answer::Cancel);
}
});
+56
View File
@@ -0,0 +1,56 @@
//! System font loading and fallback setup for multi-language (CJK/Japanese) support.
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
/// Candidate font paths to look for on different operating systems.
#[cfg(not(target_arch = "wasm32"))]
const FONT_CANDIDATES: &[&str] = &[
// Windows
"C:\\Windows\\Fonts\\meiryo.ttc",
"C:\\Windows\\Fonts\\YuGothM.ttc",
"C:\\Windows\\Fonts\\YuGothR.ttc",
"C:\\Windows\\Fonts\\msgothic.ttc",
"C:\\Windows\\Fonts\\BIZ-UDGothicR.ttc",
// macOS
"/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/Library/Fonts/Arial Unicode.ttf",
// Linux
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
];
/// Configure font definitions with system CJK fallback if available.
#[cfg(not(target_arch = "wasm32"))]
pub fn setup_fonts(ctx: &egui::Context) {
let mut font_bytes: Option<Vec<u8>> = None;
for path in FONT_CANDIDATES {
if let Ok(bytes) = std::fs::read(path)
&& ttf_parser::Face::parse(&bytes, 0).is_ok()
{
font_bytes = Some(bytes);
break;
}
}
let Some(bytes) = font_bytes else {
return;
};
let mut fonts = egui::FontDefinitions::default();
fonts.font_data.insert("cjk_fallback".to_owned(), Arc::new(egui::FontData::from_owned(bytes)));
fonts.families.entry(egui::FontFamily::Proportional).or_default().push("cjk_fallback".to_owned());
fonts.families.entry(egui::FontFamily::Monospace).or_default().push("cjk_fallback".to_owned());
ctx.set_fonts(fonts);
}
/// Browsers cannot read system font files. A web host can supply licensed fonts through egui.
#[cfg(target_arch = "wasm32")]
pub fn setup_fonts(_ctx: &egui::Context) {}
+266
View File
@@ -0,0 +1,266 @@
//! Embedded offline message catalogs. UI messages use stable IDs; engine display labels
//! pass through an adapter without changing engine or scenario identifiers.
use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::OnceLock;
type Messages = BTreeMap<String, String>;
/// Register locales here after adding their catalogs. Missing translations use English.
pub struct Catalog {
pub id: &'static str,
pub name: &'static str,
source: &'static str,
messages: OnceLock<Messages>,
}
impl Catalog {
const fn new(id: &'static str, name: &'static str, source: &'static str) -> Self {
Self { id, name, source, messages: OnceLock::new() }
}
fn messages(&'static self) -> &'static Messages {
self.messages.get_or_init(|| match serde_json::from_str(self.source) {
Ok(messages) => messages,
Err(error) => {
log::error!("invalid embedded {} message catalog: {error}", self.id);
Messages::new()
}
})
}
}
static ENGLISH: Catalog = Catalog::new("en", "English", include_str!("locales/en.json"));
static JAPANESE: Catalog = Catalog::new("ja", "日本語", include_str!("locales/ja.json"));
pub const LOCALES: &[&Catalog] = &[&ENGLISH, &JAPANESE];
pub fn is_supported(id: &str) -> bool {
LOCALES.iter().any(|catalog| catalog.id == id)
}
/// Immutable locale reference. Catalogs parse once; lookups are logarithmic.
#[derive(Clone, Copy)]
pub struct Locale(&'static Catalog);
impl Locale {
pub fn for_language(id: &str) -> Self {
Self(LOCALES.iter().copied().find(|catalog| catalog.id == id).unwrap_or(&ENGLISH))
}
pub fn name(self) -> &'static str {
self.0.name
}
/// Stable message ID → translation → English → ID.
pub fn text(self, id: &str) -> &str {
self.0.messages().get(id).or_else(|| ENGLISH.messages().get(id)).map_or(id, String::as_str)
}
/// Translate source labels from engine/UI tables, preserving unknown labels.
/// Also accepts stable IDs while table helpers migrate away from source text.
pub fn label(self, label: &str) -> &str {
static LABEL_IDS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
let ids = LABEL_IDS.get_or_init(|| {
ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')).map(|(id, text)| (text.as_str(), id.as_str())).collect()
});
ids.get(label).map_or_else(|| self.text(label), |id| self.text(id))
}
pub fn format(self, id: &str, args: &[(&str, &str)]) -> String {
interpolate(self.text(id), args)
}
/// Translate generated browser names and labels, preserving custom names otherwise.
pub fn dynamic(self, text: &str) -> String {
for (prefix, id) in [("Sketch", "browser.sketch_name"), ("Body", "browser.body_name"), ("Component", "browser.component_name")] {
if let Some(number) = text.strip_prefix(prefix)
&& !number.is_empty()
&& number.chars().all(|c| c.is_ascii_digit())
{
return self.format(id, &[("number", number)]);
}
}
for (prefix, id, arg) in [("Units: ", "browser.units", "units"), ("Precision: ", "browser.precision", "precision")] {
if let Some(value) = text.strip_prefix(prefix) {
return self.format(id, &[(arg, value)]);
}
}
if let Some(count) = text.strip_prefix("Parameters (").and_then(|s| s.strip_suffix(')')) {
return self.format("browser.parameters", &[("count", count)]);
}
if let Some(count) = text.strip_suffix(" degrees of freedom")
&& !count.is_empty()
&& count.chars().all(|c| c.is_ascii_digit())
{
return self.format("sketch.degrees_of_freedom", &[("count", count)]);
}
self.label(text).to_string()
}
}
/// One-pass substitution: argument values are literal, even when they contain braces.
/// Missing arguments remain visible rather than crashing or silently removing text.
fn interpolate(template: &str, args: &[(&str, &str)]) -> String {
let mut output = String::with_capacity(template.len());
let mut remaining = template;
while let Some((before, after)) = remaining.split_once('{') {
output.push_str(before);
let Some((name, rest)) = after.split_once('}') else {
output.push('{');
output.push_str(after);
return output;
};
if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) {
output.push_str(value);
} else {
output.push('{');
output.push_str(name);
output.push('}');
}
remaining = rest;
}
output.push_str(remaining);
output
}
thread_local! {
// Helpers without an app reference share a render scope. Nested renders restore
// their caller's locale; no owned language strings or persistent global setting.
static CURRENT: Cell<Locale> = const { Cell::new(Locale(&ENGLISH)) };
}
#[must_use]
pub struct LanguageScope {
previous: Locale,
// Drop on the thread where the scope was entered.
_thread: std::marker::PhantomData<std::rc::Rc<()>>,
}
pub fn scope(id: &str) -> LanguageScope {
let previous = CURRENT.with(|current| current.replace(Locale::for_language(id)));
LanguageScope { previous, _thread: std::marker::PhantomData }
}
impl Drop for LanguageScope {
fn drop(&mut self) {
CURRENT.with(|current| current.set(self.previous));
}
}
pub fn tr(id: &str) -> &str {
CURRENT.with(|current| current.get().text(id))
}
pub fn tr_label(label: &str) -> &str {
CURRENT.with(|current| current.get().label(label))
}
pub fn format(id: &str, args: &[(&str, &str)]) -> String {
CURRENT.with(|current| current.get().format(id, args))
}
pub fn tr_dyn(text: &str) -> String {
CURRENT.with(|current| current.get().dynamic(text))
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn placeholders(text: &str) -> BTreeSet<&str> {
let mut result = BTreeSet::new();
let mut rest = text;
while let Some((_, after)) = rest.split_once('{') {
let (name, remaining) = after.split_once('}').expect("unclosed placeholder");
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
result.insert(name);
rest = remaining;
}
result
}
#[test]
fn catalogs_have_valid_ids_and_matching_placeholders() {
let english: Messages = serde_json::from_str(ENGLISH.source).unwrap();
let mut ids = BTreeSet::new();
for catalog in LOCALES {
assert!(ids.insert(catalog.id), "duplicate locale");
let messages: Messages = serde_json::from_str(catalog.source).unwrap();
assert!(!messages.is_empty());
for (id, text) in messages {
let original = english.get(&id).expect("unknown message ID");
assert_eq!(placeholders(&text), placeholders(original), "{}: {id}", catalog.id);
}
}
}
#[test]
fn direct_ui_message_ids_exist_in_english_catalog() {
// Catch a misspelled key before it is shown verbatim in the UI. Engine-table labels
// deliberately use the separate label adapter and fall back to their source text.
for source in [
include_str!("toolbar.rs"),
include_str!("prefs.rs"),
include_str!("documents.rs"),
include_str!("dialogs.rs"),
include_str!("browser.rs"),
include_str!("sketch_palette.rs"),
] {
for call in [".tr(\"", "i18n::tr(\"", "i18n::format(\""] {
for tail in source.split(call).skip(1) {
let id = tail.split('"').next().unwrap();
assert!(ENGLISH.messages().contains_key(id), "missing English message: {id}");
}
}
}
}
#[test]
fn keys_and_engine_labels_translate_with_english_fallback() {
let ja = Locale::for_language("ja");
assert_eq!(ja.text("ui.preferences"), "環境設定");
assert_eq!(ja.label("Extrude"), "押し出し");
assert_eq!(ja.label("CREATE SKETCH"), "スケッチを作成");
assert_eq!(ja.label("NonExistentKey"), "NonExistentKey");
assert_eq!(Locale::for_language("unknown").text("ui.preferences"), "Preferences");
static PARTIAL: Catalog = Catalog::new("test", "Test", r#"{"ui.preferences":"Prefs"}"#);
assert_eq!(Locale(&PARTIAL).text("ui.preferences"), "Prefs");
assert_eq!(Locale(&PARTIAL).text("ui.extrude"), "Extrude");
}
#[test]
fn templates_allow_reordering_and_literal_braces_in_values() {
assert_eq!(interpolate("{second} then {first}", &[("first", "{second}"), ("second", "B")]), "B then {second}");
assert_eq!(interpolate("{missing} / {", &[]), "{missing} / {");
assert_eq!(Locale::for_language("ja").format("document.save_changes", &[("name", "My {design}")]), "My {design} への変更を保存しますか?");
assert_eq!(Locale::for_language("en").format("dialog.selected_count", &[("count", "2")]), "↖ 2 selected");
}
#[test]
fn browser_patterns_preserve_numbers_and_custom_names() {
let ja = Locale::for_language("ja");
assert_eq!(ja.dynamic("Sketch1"), "スケッチ1");
assert_eq!(ja.dynamic("Body2"), "ボディ2");
assert_eq!(ja.dynamic("Component12"), "コンポーネント12");
assert_eq!(ja.dynamic("Units: mm"), "単位: mm");
assert_eq!(ja.dynamic("Precision: 3"), "精度: 3");
assert_eq!(ja.dynamic("Parameters (5)"), "パラメータ (5)");
assert_eq!(ja.dynamic("4 degrees of freedom"), "自由度 4");
assert_eq!(ja.dynamic("Sketch of bracket"), "Sketch of bracket");
assert_eq!(Locale::for_language("en").dynamic("Sketch1"), "Sketch1");
}
#[test]
fn render_scopes_restore_the_previous_language() {
let _english = scope("en");
{
let _japanese = scope("ja");
assert_eq!(tr("ui.preferences"), "環境設定");
{
let _nested = scope("en");
assert_eq!(tr("ui.preferences"), "Preferences");
}
assert_eq!(tr("ui.preferences"), "環境設定");
}
assert_eq!(tr("ui.preferences"), "Preferences");
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Localization must not change scenario handles or leave the next app in another locale.
use crate::scenario::{self, Harness};
use serde_json::json;
#[test]
fn japanese_dialog_keeps_english_scenario_handles() {
let mut h = Harness::new();
h.app.preferences.language = "ja".into();
h.app.home.open = false;
h.call("ui.start", json!({"command":"sketch.create"}));
h.frames(3);
assert!(h.widget("平面").is_some(), "the input label must be translated");
assert!(h.widget("キャンセル").is_some(), "the close button must be translated");
assert!(!scenario::handles("dialog:Plane").is_empty(), "scenario handles must retain source labels");
assert_eq!(crate::i18n::tr("ui.preferences"), "Preferences", "the render scope must restore English");
h.app.preferences.language = "en".into();
h.frames(3);
assert!(h.widget("Plane").is_some());
assert!(h.widget("Cancel").is_some());
}
#[test]
fn language_preference_cancel_restores_previous_locale() {
let mut h = Harness::new();
h.app.home.open = false;
h.call("ui.prefs", json!({"open":"General"}));
h.call("ui.prefs", json!({"language":"ja"}));
assert!(h.widget("言語").is_some());
assert_eq!(h.app.preferences.language, "ja");
h.call("ui.prefs", json!({"button":"cancel"}));
assert_eq!(h.app.preferences.language, "en");
assert_eq!(h.app.tr("ui.preferences"), "Preferences");
}
#[test]
fn translated_dialog_buttons_do_not_wrap() {
fn cancel_rows(shape: &egui::Shape) -> Option<usize> {
match shape {
egui::Shape::Text(text) if text.galley.job.text == "キャンセル" => Some(text.galley.rows.len()),
egui::Shape::Vec(shapes) => shapes.iter().find_map(cancel_rows),
_ => None,
}
}
let mut h = Harness::new();
crate::fonts::setup_fonts(&h.ctx);
h.app.preferences.language = "ja".into();
h.app.home.open = false;
h.call("ui.start", json!({"command":"solid.extrude"}));
let mut out = h.ctx.run_ui(
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() },
|ui| h.app.ui(ui),
);
assert_eq!(out.shapes.iter().find_map(|shape| cancel_rows(&shape.shape)), Some(1));
out.textures_delta.clear();
}
+15
View File
@@ -27,11 +27,15 @@ pub mod dialogs_surface;
pub mod dim_view;
pub mod documents;
pub mod drag_snap;
pub mod fonts;
pub mod frame;
pub mod gizmo;
pub mod gpu;
pub mod help;
pub mod home;
pub mod i18n;
#[cfg(test)]
mod i18n_ui_tests;
pub mod icons;
pub mod inference;
pub mod keymap;
@@ -299,6 +303,16 @@ impl SolveApp {
}
}
/// Translate text using the active language preference.
pub fn tr<'a>(&self, text: &'a str) -> &'a str {
i18n::Locale::for_language(&self.preferences.language).text(text)
}
/// Translate a display label supplied by the engine, without changing its identifier.
pub fn tr_label<'a>(&self, text: &'a str) -> &'a str {
i18n::Locale::for_language(&self.preferences.language).label(text)
}
/// Draw the viewport on the GPU (eframe's wgpu render state, with the app's depth and
/// stencil buffer bits and MSAA sample count). Without it the viewport renders on the CPU.
pub fn set_wgpu(&mut self, rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32) {
@@ -838,6 +852,7 @@ impl SolveApp {
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let t0 = now_ms();
let _language = crate::i18n::scope(&self.preferences.language);
scenario::clear_handles();
toolbar::app_bar(self, ui);
file_load::show(self, ui.ctx());
+296
View File
@@ -0,0 +1,296 @@
{
"browser.body_name": "Body{number}",
"browser.component_name": "Component{number}",
"browser.parameters": "Parameters ({count})",
"browser.precision": "Precision: {precision}",
"browser.sketch_name": "Sketch{number}",
"browser.units": "Units: {units}",
"dialog.custom_axis": "↖ Custom Axis",
"dialog.edit_title": "EDIT {title}",
"dialog.select": "↖ Select",
"dialog.selected_count": "↖ {count} selected",
"document.save_changes": "Save changes to {name}?",
"document.unsaved_changes": "{name} has unsaved changes. If you don't save, they are lost.",
"sketch.degrees_of_freedom": "{count} degrees of freedom",
"sketch.draw_curves": "Draw {linetype} curves",
"sketch.make_selection": "Make the selection {linetype}",
"ui.1_degree_of_freedom": "1 degree of freedom",
"ui.3d_sketch": "3D Sketch",
"ui.about": "About",
"ui.activate": "Activate",
"ui.active_component": "Active component",
"ui.agent_cursor": "Agent cursor",
"ui.agent_cursor_speed": "Agent cursor speed",
"ui.all": "All",
"ui.angle": "Angle",
"ui.angle_decimals": "Angle decimals",
"ui.animate_view_changes": "Animate view changes",
"ui.animation": "Animation",
"ui.animation_speed": "Animation speed",
"ui.apply": "Apply",
"ui.arc": "Arc",
"ui.assemble": "Assemble",
"ui.autosave_every": "Autosave every",
"ui.axes": "Axes",
"ui.axis": "Axis",
"ui.bodies": "Bodies",
"ui.box": "Box",
"ui.break": "Break",
"ui.cancel": "Cancel",
"ui.canvases": "Canvases",
"ui.centerline": "Centerline",
"ui.chamfer": "Chamfer",
"ui.change_workspace": "Change workspace",
"ui.chord_length": "Chord Length",
"ui.circle": "Circle",
"ui.circular_pattern": "Circular Pattern",
"ui.clear_recent": "Clear Recent",
"ui.clear_the_selection": "Clear the selection",
"ui.close": "Close",
"ui.coil": "Coil",
"ui.coincident": "Coincident",
"ui.collapse": "Collapse",
"ui.collinear": "Collinear",
"ui.combine": "Combine",
"ui.coming_later": "coming later",
"ui.concentric": "Concentric",
"ui.conic_curve": "Conic Curve",
"ui.constant": "Constant",
"ui.constraints_conflict": "Constraints conflict",
"ui.construct": "Construct",
"ui.construction": "Construction",
"ui.copy": "Copy",
"ui.counterbore": "Counterbore",
"ui.countersink": "Countersink",
"ui.create": "Create",
"ui.create_sketch": "Create Sketch",
"ui.current": "current",
"ui.curvature": "Curvature",
"ui.cut": "Cut",
"ui.cylinder": "Cylinder",
"ui.dark": "Dark",
"ui.default_modelling_orientation": "Default modelling orientation",
"ui.degrees_of_freedom": "degrees of freedom",
"ui.delete": "Delete",
"ui.depth": "Depth",
"ui.design": "Design",
"ui.diameter": "Diameter",
"ui.dimension": "Dimension",
"ui.direction": "Direction",
"ui.distance": "Distance",
"ui.distance_1": "Distance 1",
"ui.distance_2": "Distance 2",
"ui.document_settings": "Document Settings",
"ui.don_t_save": "Don't Save",
"ui.draft": "Draft",
"ui.drawing": "Drawing",
"ui.drill_point": "Drill Point",
"ui.edit": "Edit",
"ui.ellipse": "Ellipse",
"ui.end_radius": "End Radius",
"ui.equal": "Equal",
"ui.expand": "Expand",
"ui.export_ellipsis": "Export...",
"ui.extend": "Extend",
"ui.extent_type": "Extent Type",
"ui.extrude": "Extrude",
"ui.features": "Features",
"ui.file": "File",
"ui.fillet": "Fillet",
"ui.finish_sketch": "Finish Sketch",
"ui.fix_unfix": "Fix/Unfix",
"ui.flat": "Flat",
"ui.flip": "Flip",
"ui.front": "Front",
"ui.fully_constrained": "Fully constrained",
"ui.general": "General",
"ui.generative_design": "Generative Design",
"ui.graphics": "Graphics",
"ui.heading_assemble": "ASSEMBLE",
"ui.heading_box": "BOX",
"ui.heading_chamfer": "CHAMFER",
"ui.heading_circular_pattern": "CIRCULAR PATTERN",
"ui.heading_coil": "COIL",
"ui.heading_combine": "COMBINE",
"ui.heading_construct": "CONSTRUCT",
"ui.heading_create": "CREATE",
"ui.heading_create_sketch": "CREATE SKETCH",
"ui.heading_cylinder": "CYLINDER",
"ui.heading_design": "DESIGN",
"ui.heading_draft": "DRAFT",
"ui.heading_edit": "EDIT",
"ui.heading_extrude": "EXTRUDE",
"ui.heading_fillet": "FILLET",
"ui.heading_finish_sketch": "FINISH SKETCH",
"ui.heading_front": "FRONT",
"ui.heading_hole": "HOLE",
"ui.heading_home": "HOME",
"ui.heading_insert": "INSERT",
"ui.heading_inspect": "INSPECT",
"ui.heading_loft": "LOFT",
"ui.heading_measure": "MEASURE",
"ui.heading_mesh": "MESH",
"ui.heading_mirror": "MIRROR",
"ui.heading_modify": "MODIFY",
"ui.heading_move": "MOVE",
"ui.heading_offset_plane": "OFFSET PLANE",
"ui.heading_ok": "OK",
"ui.heading_parameters": "PARAMETERS",
"ui.heading_pattern_on_path": "PATTERN ON PATH",
"ui.heading_pipe": "PIPE",
"ui.heading_plane_at_angle": "PLANE AT ANGLE",
"ui.heading_plastic": "PLASTIC",
"ui.heading_rectangular_pattern": "RECTANGULAR PATTERN",
"ui.heading_revolve": "REVOLVE",
"ui.heading_right": "RIGHT",
"ui.heading_scale": "SCALE",
"ui.heading_section_analysis": "SECTION ANALYSIS",
"ui.heading_select": "SELECT",
"ui.heading_sheet_metal": "SHEET METAL",
"ui.heading_shell": "SHELL",
"ui.heading_sketch": "SKETCH",
"ui.heading_sketch_palette": "SKETCH PALETTE",
"ui.heading_solid": "SOLID",
"ui.heading_sphere": "SPHERE",
"ui.heading_split_body": "SPLIT BODY",
"ui.heading_surface": "SURFACE",
"ui.heading_sweep": "SWEEP",
"ui.heading_thread": "THREAD",
"ui.heading_top": "TOP",
"ui.heading_torus": "TORUS",
"ui.help": "Help",
"ui.hide": "Hide",
"ui.hole": "Hole",
"ui.hole_tap_type": "Hole Tap Type",
"ui.hole_type": "Hole Type",
"ui.home": "Home",
"ui.horizontal_vertical": "Horizontal/Vertical",
"ui.insert": "Insert",
"ui.inspect": "Inspect",
"ui.intersect": "Intersect",
"ui.join": "Join",
"ui.joints": "Joints",
"ui.keyboard_shortcuts": "Keyboard Shortcuts",
"ui.language": "Language",
"ui.length_decimals": "Length decimals",
"ui.light": "Light",
"ui.line": "Line",
"ui.linetype": "Linetype",
"ui.loft": "Loft",
"ui.look_at": "Look At",
"ui.manufacture": "Manufacture",
"ui.measure": "Measure",
"ui.midpoint": "Midpoint",
"ui.mirror": "Mirror",
"ui.modify": "Modify",
"ui.move": "Move",
"ui.multiple": "Multiple",
"ui.named_views": "Named Views",
"ui.navigation": "Navigation",
"ui.new_body": "New Body",
"ui.new_design": "New Design",
"ui.normal": "Normal",
"ui.object_type": "Object Type",
"ui.objects": "Objects",
"ui.offer_to_stand_up_y_up_files": "Offer to stand up Y-up files",
"ui.offset": "Offset",
"ui.offset_plane": "Offset Plane",
"ui.one_side": "One Side",
"ui.open": "Open",
"ui.open_ellipsis": "Open...",
"ui.open_recent": "Open Recent",
"ui.operation": "Operation",
"ui.options": "Options",
"ui.orbit_around": "Orbit around",
"ui.origin": "Origin",
"ui.pan_zoom_orbit": "Pan, zoom, orbit",
"ui.parallel": "Parallel",
"ui.parameters": "Parameters",
"ui.paste": "Paste",
"ui.path": "Path",
"ui.pattern_on_path": "Pattern on Path",
"ui.perpendicular": "Perpendicular",
"ui.pipe": "Pipe",
"ui.placement": "Placement",
"ui.plane": "Plane",
"ui.plane_at_angle": "Plane at Angle",
"ui.point": "Point",
"ui.point_under_the_cursor": "Point under the cursor",
"ui.points": "Points",
"ui.polygon": "Polygon",
"ui.preferences": "Preferences",
"ui.profile": "Profile",
"ui.profile_plane": "Profile Plane",
"ui.profiles": "Profiles",
"ui.project": "Project",
"ui.quit": "Quit",
"ui.radius": "Radius",
"ui.radius_type": "Radius Type",
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Rectangular Pattern",
"ui.redo": "Redo",
"ui.render": "Render",
"ui.reset_the_settings_on_this_page": "Reset the settings on this page",
"ui.restore_defaults": "Restore Defaults",
"ui.reverse_zoom_direction": "Reverse zoom direction",
"ui.revolve": "Revolve",
"ui.right": "Right",
"ui.save": "Save",
"ui.save_as": "Save As",
"ui.save_as_ellipsis": "Save As...",
"ui.scale": "Scale",
"ui.section_analysis": "Section Analysis",
"ui.select": "Select",
"ui.selection": "Selection",
"ui.shell": "Shell",
"ui.show": "Show",
"ui.show_constraints": "Show Constraints",
"ui.show_dimensions": "Show Dimensions",
"ui.show_points": "Show Points",
"ui.show_profile": "Show Profile",
"ui.show_projected_geometries": "Show Projected Geometries",
"ui.simple": "Simple",
"ui.simulation": "Simulation",
"ui.single": "Single",
"ui.sketch": "Sketch",
"ui.sketch_dimension": "Sketch Dimension",
"ui.sketch_grid": "Sketch Grid",
"ui.sketch_scale": "Sketch Scale",
"ui.sketches": "Sketches",
"ui.slice": "Slice",
"ui.slot": "Slot",
"ui.smooth": "Smooth",
"ui.snap": "Snap",
"ui.sphere": "Sphere",
"ui.spline": "Spline",
"ui.split_body": "Split Body",
"ui.start": "Start",
"ui.start_radius": "Start Radius",
"ui.sweep": "Sweep",
"ui.symmetric": "Symmetric",
"ui.symmetry": "Symmetry",
"ui.system": "System",
"ui.tangent": "Tangent",
"ui.taper_angle": "Taper Angle",
"ui.tapped": "Tapped",
"ui.text": "Text",
"ui.theme": "Theme",
"ui.thickness": "Thickness",
"ui.thread": "Thread",
"ui.tip_angle": "Tip Angle",
"ui.to_object": "To Object",
"ui.top": "Top",
"ui.torus": "Torus",
"ui.trim": "Trim",
"ui.two_sides": "Two Sides",
"ui.ui_scale": "UI scale",
"ui.undo": "Undo",
"ui.units": "Units",
"ui.units_of_new_designs": "Units of new designs",
"ui.untitled": "Untitled",
"ui.variable": "Variable",
"ui.view_centre": "View centre",
"ui.visual_style": "Visual style",
"ui.workspace": "workspace",
"ui.zoom_to_the_cursor": "Zoom to the cursor"
}
+296
View File
@@ -0,0 +1,296 @@
{
"browser.body_name": "ボディ{number}",
"browser.component_name": "コンポーネント{number}",
"browser.parameters": "パラメータ ({count})",
"browser.precision": "精度: {precision}",
"browser.sketch_name": "スケッチ{number}",
"browser.units": "単位: {units}",
"dialog.custom_axis": "↖ カスタム軸",
"dialog.edit_title": "{title}を編集",
"dialog.select": "↖ 選択",
"dialog.selected_count": "↖ {count} 個選択",
"document.save_changes": "{name} への変更を保存しますか?",
"document.unsaved_changes": "{name} には保存されていない変更があります。保存しない場合、変更内容は失われます。",
"sketch.degrees_of_freedom": "自由度 {count}",
"sketch.draw_curves": "{linetype}の曲線を描く",
"sketch.make_selection": "選択を{linetype}にする",
"ui.1_degree_of_freedom": "自由度 1",
"ui.3d_sketch": "3D スケッチ",
"ui.about": "SolveCraft について",
"ui.activate": "アクティブ化",
"ui.active_component": "アクティブなコンポーネント",
"ui.agent_cursor": "AIエージェントカーソル",
"ui.agent_cursor_speed": "AIエージェント速度",
"ui.all": "すべて",
"ui.angle": "角度",
"ui.angle_decimals": "角度の小数点桁数",
"ui.animate_view_changes": "視点変更をアニメーション表示",
"ui.animation": "アニメーション",
"ui.animation_speed": "アニメーション速度",
"ui.apply": "適用",
"ui.arc": "円弧",
"ui.assemble": "アセンブリ",
"ui.autosave_every": "自動保存の間隔",
"ui.axes": "軸",
"ui.axis": "軸",
"ui.bodies": "ボディ",
"ui.box": "直方体",
"ui.break": "分割",
"ui.cancel": "キャンセル",
"ui.canvases": "キャンバス",
"ui.centerline": "中心線",
"ui.chamfer": "面取り",
"ui.change_workspace": "ワークスペースの変更",
"ui.chord_length": "弦長",
"ui.circle": "円",
"ui.circular_pattern": "円形パターン",
"ui.clear_recent": "最近使った項目をクリア",
"ui.clear_the_selection": "選択をクリア",
"ui.close": "閉じる",
"ui.coil": "コイル",
"ui.coincident": "一致",
"ui.collapse": "折りたたむ",
"ui.collinear": "同一直線",
"ui.combine": "結合",
"ui.coming_later": "準備中",
"ui.concentric": "同心円",
"ui.conic_curve": "円錐曲線",
"ui.constant": "一定",
"ui.constraints_conflict": "拘束が競合しています",
"ui.construct": "構築",
"ui.construction": "構築",
"ui.copy": "コピー",
"ui.counterbore": "座ぐり",
"ui.countersink": "皿もみ",
"ui.create": "作成",
"ui.create_sketch": "スケッチを作成",
"ui.current": "現在",
"ui.curvature": "曲率",
"ui.cut": "切り取り",
"ui.cylinder": "円柱",
"ui.dark": "ダーク",
"ui.default_modelling_orientation": "デフォルトのモデル方向",
"ui.degrees_of_freedom": "自由度",
"ui.delete": "削除",
"ui.depth": "深さ",
"ui.design": "デザイン",
"ui.diameter": "直径",
"ui.dimension": "寸法",
"ui.direction": "方向",
"ui.distance": "距離",
"ui.distance_1": "距離 1",
"ui.distance_2": "距離 2",
"ui.document_settings": "ドキュメントの設定",
"ui.don_t_save": "保存しない",
"ui.draft": "勾配",
"ui.drawing": "図面",
"ui.drill_point": "先端",
"ui.edit": "編集",
"ui.ellipse": "楕円",
"ui.end_radius": "終了半径",
"ui.equal": "等しい",
"ui.expand": "展開",
"ui.export_ellipsis": "エクスポート...",
"ui.extend": "延長",
"ui.extent_type": "範囲タイプ",
"ui.extrude": "押し出し",
"ui.features": "フィーチャー",
"ui.file": "ファイル",
"ui.fillet": "フィレット",
"ui.finish_sketch": "スケッチを終了",
"ui.fix_unfix": "固定/固定解除",
"ui.flat": "フラット",
"ui.flip": "反転",
"ui.front": "正面",
"ui.fully_constrained": "完全拘束",
"ui.general": "一般",
"ui.generative_design": "ジェネレーティブ デザイン",
"ui.graphics": "グラフィックス",
"ui.heading_assemble": "アセンブリ",
"ui.heading_box": "直方体",
"ui.heading_chamfer": "面取り",
"ui.heading_circular_pattern": "円形パターン",
"ui.heading_coil": "コイル",
"ui.heading_combine": "結合",
"ui.heading_construct": "構築",
"ui.heading_create": "作成",
"ui.heading_create_sketch": "スケッチを作成",
"ui.heading_cylinder": "円柱",
"ui.heading_design": "デザイン",
"ui.heading_draft": "勾配",
"ui.heading_edit": "編集",
"ui.heading_extrude": "押し出し",
"ui.heading_fillet": "フィレット",
"ui.heading_finish_sketch": "スケッチを終了",
"ui.heading_front": "正面",
"ui.heading_hole": "穴",
"ui.heading_home": "ホーム",
"ui.heading_insert": "挿入",
"ui.heading_inspect": "検査",
"ui.heading_loft": "ロフト",
"ui.heading_measure": "計測",
"ui.heading_mesh": "メッシュ",
"ui.heading_mirror": "ミラー",
"ui.heading_modify": "修正",
"ui.heading_move": "移動",
"ui.heading_offset_plane": "オフセット平面",
"ui.heading_ok": "OK",
"ui.heading_parameters": "パラメータ",
"ui.heading_pattern_on_path": "パス上のパターン",
"ui.heading_pipe": "パイプ",
"ui.heading_plane_at_angle": "傾斜平面",
"ui.heading_plastic": "プラスチック",
"ui.heading_rectangular_pattern": "矩形パターン",
"ui.heading_revolve": "回転",
"ui.heading_right": "右側面",
"ui.heading_scale": "尺度",
"ui.heading_section_analysis": "断面解析",
"ui.heading_select": "選択",
"ui.heading_sheet_metal": "板金",
"ui.heading_shell": "シェル",
"ui.heading_sketch": "スケッチ",
"ui.heading_sketch_palette": "スケッチ パレット",
"ui.heading_solid": "ソリッド",
"ui.heading_sphere": "球",
"ui.heading_split_body": "ボディを分割",
"ui.heading_surface": "サーフェス",
"ui.heading_sweep": "スイープ",
"ui.heading_thread": "ねじ",
"ui.heading_top": "上面",
"ui.heading_torus": "トーラス",
"ui.help": "ヘルプ",
"ui.hide": "非表示",
"ui.hole": "穴",
"ui.hole_tap_type": "タップタイプ",
"ui.hole_type": "穴タイプ",
"ui.home": "ホーム",
"ui.horizontal_vertical": "水平/垂直",
"ui.insert": "挿入",
"ui.inspect": "検査",
"ui.intersect": "交差",
"ui.join": "結合",
"ui.joints": "ジョイント",
"ui.keyboard_shortcuts": "キーボードショートカット",
"ui.language": "言語",
"ui.length_decimals": "長さの小数点桁数",
"ui.light": "ライト",
"ui.line": "線分",
"ui.linetype": "線種",
"ui.loft": "ロフト",
"ui.look_at": "視点を合わせる",
"ui.manufacture": "製造",
"ui.measure": "計測",
"ui.midpoint": "中点",
"ui.mirror": "ミラー",
"ui.modify": "修正",
"ui.move": "移動",
"ui.multiple": "複数",
"ui.named_views": "名前付きビュー",
"ui.navigation": "ナビゲーション",
"ui.new_body": "新規ボディ",
"ui.new_design": "新規デザイン",
"ui.normal": "標準",
"ui.object_type": "オブジェクトタイプ",
"ui.objects": "オブジェクト",
"ui.offer_to_stand_up_y_up_files": "Y-upファイルを立ち上げるか確認",
"ui.offset": "オフセット",
"ui.offset_plane": "オフセット平面",
"ui.one_side": "片側",
"ui.open": "開く",
"ui.open_ellipsis": "開く...",
"ui.open_recent": "最近使った項目を開く",
"ui.operation": "操作",
"ui.options": "オプション",
"ui.orbit_around": "回転の中心",
"ui.origin": "原点",
"ui.pan_zoom_orbit": "パン、ズーム、オービット",
"ui.parallel": "平行",
"ui.parameters": "パラメータ",
"ui.paste": "貼り付け",
"ui.path": "パス",
"ui.pattern_on_path": "パス上のパターン",
"ui.perpendicular": "直交",
"ui.pipe": "パイプ",
"ui.placement": "配置",
"ui.plane": "平面",
"ui.plane_at_angle": "傾斜平面",
"ui.point": "点",
"ui.point_under_the_cursor": "カーソル下の点",
"ui.points": "点",
"ui.polygon": "ポリゴン",
"ui.preferences": "環境設定",
"ui.profile": "プロファイル",
"ui.profile_plane": "プロファイル平面",
"ui.profiles": "プロファイル",
"ui.project": "投影",
"ui.quit": "終了",
"ui.radius": "半径",
"ui.radius_type": "半径タイプ",
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
"ui.redo": "やり直す",
"ui.render": "レンダリング",
"ui.reset_the_settings_on_this_page": "このページの設定をリセット",
"ui.restore_defaults": "初期値に戻す",
"ui.reverse_zoom_direction": "ズーム方向を反転",
"ui.revolve": "回転",
"ui.right": "右側面",
"ui.save": "保存",
"ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存...",
"ui.scale": "尺度",
"ui.section_analysis": "断面解析",
"ui.select": "選択",
"ui.selection": "選択",
"ui.shell": "シェル",
"ui.show": "表示",
"ui.show_constraints": "拘束を表示",
"ui.show_dimensions": "寸法を表示",
"ui.show_points": "点を表示",
"ui.show_profile": "プロファイルを表示",
"ui.show_projected_geometries": "投影ジオメトリを表示",
"ui.simple": "単純",
"ui.simulation": "シミュレーション",
"ui.single": "単一",
"ui.sketch": "スケッチ",
"ui.sketch_dimension": "スケッチ寸法",
"ui.sketch_grid": "スケッチ グリッド",
"ui.sketch_scale": "スケッチ尺度",
"ui.sketches": "スケッチ",
"ui.slice": "スライス",
"ui.slot": "スロット",
"ui.smooth": "スムーズ",
"ui.snap": "スナップ",
"ui.sphere": "球",
"ui.spline": "スプライン",
"ui.split_body": "ボディを分割",
"ui.start": "開始",
"ui.start_radius": "開始半径",
"ui.sweep": "スイープ",
"ui.symmetric": "対称",
"ui.symmetry": "対称",
"ui.system": "システム",
"ui.tangent": "正接",
"ui.taper_angle": "テーパ角度",
"ui.tapped": "タップ立て",
"ui.text": "テキスト",
"ui.theme": "テーマ",
"ui.thickness": "厚さ",
"ui.thread": "ねじ",
"ui.tip_angle": "先端角度",
"ui.to_object": "オブジェクトまで",
"ui.top": "上面",
"ui.torus": "トーラス",
"ui.trim": "トリム",
"ui.two_sides": "両側",
"ui.ui_scale": "UIスケール",
"ui.undo": "元に戻す",
"ui.units": "単位",
"ui.units_of_new_designs": "新規デザインの単位",
"ui.untitled": "無題",
"ui.variable": "可変",
"ui.view_centre": "ビュー中心",
"ui.visual_style": "表示スタイル",
"ui.workspace": "ワークスペース",
"ui.zoom_to_the_cursor": "カーソル位置へズーム"
}
+59 -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 (a registered locale ID).
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("ui.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_label(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_label(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("ui.restore_defaults")).on_hover_text(app.tr("ui.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("ui.heading_ok")).clicked() {
button = Some("ok");
}
if ui.button("Cancel").clicked() {
if ui.button(app.tr("ui.cancel")).clicked() {
button = Some("cancel");
}
if ui.button("Apply").clicked() {
if ui.button(app.tr("ui.apply")).clicked() {
button = Some("apply");
}
});
@@ -467,6 +471,7 @@ fn up_axis_changed(app: &mut SolveApp) {
/// One page of the dialog.
fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
let _language = crate::i18n::scope(&app.preferences.language);
let note = |ui: &mut egui::Ui, s: &str| {
ui.label("");
ui.label(RichText::new(s).size(11.0).color(t.text_dim));
@@ -478,10 +483,21 @@ 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("ui.language"));
egui::ComboBox::from_id_salt("pref_language").selected_text(crate::i18n::Locale::for_language(&app.preferences.language).name()).show_ui(
ui,
|ui| {
for locale in crate::i18n::LOCALES {
let (id, label) = (locale.id, locale.name);
ui.selectable_value(&mut app.preferences.language, id.to_string(), label);
}
},
);
ui.end_row();
ui.label(app.tr("ui.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(label)).clicked() {
app.preferences.theme = id.to_string();
if id != "system" {
app.ui.dark = id == "dark";
@@ -490,33 +506,34 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
}
});
ui.end_row();
ui.label("UI scale");
ui.label(app.tr("ui.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("ui.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("ui.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("ui.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("ui.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("ui.agent_cursor"));
let speed_label = app.tr("ui.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 +710,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::is_supported(&new.language) {
return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::<Vec<_>>().join(", ")));
}
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 +798,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("ui.preferences"), "Preferences");
assert_eq!(app.tr("ui.extrude"), "Extrude");
// Switch language to Japanese
set(&mut app, &json!({"language": "ja"})).unwrap();
assert_eq!(app.preferences.language, "ja");
assert_eq!(app.tr("ui.preferences"), "環境設定");
assert_eq!(app.tr("ui.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("ui.preferences"), "環境設定");
}
/// A STEP file opened Z-up is offered to be stood up: a quarter turn about X, undoable.
+21 -11
View File
@@ -167,6 +167,7 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) {
/// Draw the palette (while sketching and no command dialog is open).
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let _language = crate::i18n::scope(&app.preferences.language);
if app.dialog.is_some() {
return;
}
@@ -201,7 +202,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let mut finish = false;
let has_curves = !selected_curves(app).is_empty();
crate::frame::docked(
"SKETCH PALETTE",
app.tr("ui.heading_sketch_palette"),
egui::Id::new("sc_sketch_palette"),
anchor,
ctx.content_rect().bottom() - anchor.y - 8.0,
@@ -215,39 +216,47 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() {
FOLDED.with(|f| f.set(!folded));
}
ui.label(RichText::new("SKETCH PALETTE").strong().size(12.5));
ui.label(RichText::new(app.tr("ui.heading_sketch_palette")).strong().size(12.5));
});
if FOLDED.with(Cell::get) {
return;
}
let ofold = OPTIONS_FOLDED.with(Cell::get);
if ui.add(egui::Button::new(RichText::new(format!("{} Options", if ofold { "+" } else { "−" })).size(12.0)).frame(false)).clicked() {
if ui
.add(egui::Button::new(RichText::new(format!("{} {}", if ofold { "+" } else { "−" }, app.tr("ui.options"))).size(12.0)).frame(false))
.clicked()
{
OPTIONS_FOLDED.with(|f| f.set(!ofold));
}
if !ofold {
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Linetype");
ui.label(app.tr("ui.linetype"));
ui.horizontal(|ui| {
// With a selection: convert it; without: the mode new curves are drawn in.
let current = mode(app);
for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() {
let on = !has_curves && current == i as u8;
let tip = if has_curves { format!("Make the selection {tip}") } else { format!("Draw {tip} curves") };
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() {
let tr_tip = app.tr_label(tip);
let tip_str = if has_curves {
crate::i18n::format("sketch.make_selection", &[("linetype", tr_tip)])
} else {
crate::i18n::format("sketch.draw_curves", &[("linetype", tr_tip)])
};
if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip_str).clicked() {
linetype = Some(i as u8);
}
}
});
ui.end_row();
ui.label("Look At");
if ui.button("Look At").clicked() {
ui.label(app.tr("ui.look_at"));
if ui.button(app.tr("ui.look_at")).clicked() {
look = true;
}
ui.end_row();
for (k, label) in TOGGLES {
// The label is part of the checkbox: clicking it toggles too.
let mut on = shown(k);
if ui.checkbox(&mut on, label).changed() {
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
set = Some((k.to_string(), on));
}
ui.end_row();
@@ -255,11 +264,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
});
}
ui.add_space(4.0);
ui.label(RichText::new(dof_line.as_str()).color(if ok { t.sketch_accent } else { t.text_dim }));
let tr_dof = crate::i18n::tr_dyn(&dof_line);
ui.label(RichText::new(tr_dof).color(if ok { t.sketch_accent } else { t.text_dim }));
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(WIDTH - 100.0);
if ui.add(egui::Button::new(RichText::new("Finish Sketch").color(Color32::WHITE)).fill(t.accent)).clicked() {
if ui.add(egui::Button::new(RichText::new(app.tr("ui.finish_sketch")).color(Color32::WHITE)).fill(t.accent)).clicked() {
finish = true;
}
});
+37 -26
View File
@@ -68,26 +68,26 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
);
resp.on_hover_text(tip).clicked()
};
let file = click(ui, "folder", "File");
if click(ui, "save", "Save (Ctrl+S)") {
let file = click(ui, "folder", app.tr("ui.file"));
if click(ui, "save", &format!("{} (Ctrl+S)", app.tr("ui.save"))) {
save(app);
}
if click(ui, "undo", "Undo (Ctrl+Z)") {
if click(ui, "undo", &format!("{} (Ctrl+Z)", app.tr("ui.undo"))) {
let _ = app.run("edit.undo", json!({}));
}
if click(ui, "redo", "Redo (Ctrl+Y)") {
if click(ui, "redo", &format!("{} (Ctrl+Y)", app.tr("ui.redo"))) {
let _ = app.run("edit.redo", json!({}));
}
if click(ui, "home", "Start page") {
if click(ui, "home", app.tr("ui.home")) {
let rev = app.session.revision;
app.home.show(rev);
}
let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" };
let theme_tip = if app.ui.dark { app.tr("ui.light") } else { app.tr("ui.dark") };
if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) {
app.ui.dark = !app.ui.dark;
app.preferences.theme = if app.ui.dark { "dark" } else { "light" }.into();
}
if click(ui, "help", "Help") {
if click(ui, "help", app.tr("ui.help")) {
app.help.menu = !app.help.menu;
let at = ui.ctx().input(|i| i.pointer.latest_pos()).map_or(300.0, |p| p.x);
ui.ctx().data_mut(|d| {
@@ -118,7 +118,12 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
icons::paint(ui.painter(), gear.shrink(4.0), "settings", t.app_bar_text, t.app_bar_text, t.app_bar);
crate::scenario::publish_handle("titlebar:preferences", gear.center());
if gresp.on_hover_text(if cfg!(target_os = "macos") { "Preferences (Cmd+,)" } else { "Preferences (Ctrl+,)" }).clicked() {
let prefs_tip = if cfg!(target_os = "macos") {
format!("{} (Cmd+,)", app.tr("ui.preferences"))
} else {
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
};
if gresp.on_hover_text(prefs_tip).clicked() {
crate::prefs::open_at(app, 0);
}
ui.painter().text(
@@ -155,17 +160,17 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
};
if item(ui, "New Design", "Ctrl+N") {
if item(ui, app.tr("ui.new_design"), "Ctrl+N") {
crate::documents::new_design(app);
close = true;
}
if item(ui, "Open…", "Ctrl+O") {
if item(ui, &format!("{}…", app.tr("ui.open")), "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path_async(&p);
}
close = true;
}
ui.menu_button("Open Recent", |ui| {
ui.menu_button(app.tr("ui.open_recent"), |ui| {
for path in app.home.recent.clone() {
let missing = !solvecraft_engine::io::vfs::exists(&path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
@@ -176,7 +181,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
}
ui.separator();
if ui.button("Clear Recent").clicked() {
if ui.button(app.tr("ui.clear_recent")).clicked() {
app.home.recent.clear();
app.home.opened_at.clear();
close = true;
@@ -191,11 +196,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
app.start("file.insert_mesh");
close = true;
}
if item(ui, "Save", "Ctrl+S") {
if item(ui, app.tr("ui.save"), "Ctrl+S") {
save(app);
close = true;
}
if item(ui, "Save As…", "") {
if item(ui, &format!("{}…", app.tr("ui.save_as")), "") {
save_as(app);
close = true;
}
@@ -217,16 +222,16 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
close = true;
}
ui.separator();
if item(ui, "Preferences…", "") {
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
app.prefs_window.open = true;
close = true;
}
if item(ui, "Keyboard Shortcuts…", "") {
if item(ui, &format!("{}…", app.tr("ui.keyboard_shortcuts")), "") {
app.keymap.open = true;
app.keymap.read_only = false;
close = true;
}
if item(ui, "Quit", "") {
if item(ui, app.tr("ui.quit"), "") {
crate::documents::request_quit(app);
close = true;
}
@@ -282,13 +287,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.text(
pos2(ws.center().x - 5.0, ws.top() + 52.0),
Align2::CENTER_CENTER,
crate::toolbar_custom::WORKSPACE,
app.tr_label(crate::toolbar_custom::WORKSPACE),
FontId::proportional(12.0),
t.text,
);
caret(&painter, pos2(ws.center().x + 27.0, ws.top() + 52.0), t.text);
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, "workspace", FontId::proportional(10.0), t.text_dim);
if wresp.on_hover_text("Change workspace").clicked() {
painter.text(pos2(ws.center().x, ws.top() + 69.0), Align2::CENTER_CENTER, app.tr("ui.workspace"), FontId::proportional(10.0), t.text_dim);
if wresp.on_hover_text(app.tr("ui.change_workspace")).clicked() {
crate::context_menu::open_for(app, ws.left_bottom(), crate::context_menu::Target::Workspace);
}
crate::scenario::publish_rect("toolbar", r);
@@ -300,7 +305,9 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
tabs.extend(TABS.iter());
for tab in tabs {
let w = 12.0 + tab.len() as f32 * 7.4;
let display_tab = app.tr_label(tab);
let text_width = painter.layout_no_wrap(display_tab.to_string(), FontId::proportional(12.0), t.text).size().x;
let w = 12.0 + text_width.max(28.0);
let tr = Rect::from_min_size(pos2(x, tabs_y), vec2(w, 22.0));
let active = app.ui.tab == tab;
let resp = ui.interact(tr, ui.id().with(("tab", tab)), Sense::click());
@@ -317,7 +324,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
} else {
t.text_dim
};
painter.text(tr.center(), Align2::CENTER_CENTER, tab, FontId::proportional(12.0), col);
painter.text(tr.center(), Align2::CENTER_CENTER, display_tab, FontId::proportional(12.0), col);
if resp.clicked() {
app.ui.tab = tab.to_string();
}
@@ -384,9 +391,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
};
let icon = if c.id == "sketch.finish" { "finish" } else { c.icon };
icons::paint(&painter, br.shrink(6.0), icon, ink, fill, acc);
let tr_label = app.tr_label(c.label);
let tip = match &info.disabled_reason {
Some(why) => format!("{}\n{why}", c.label),
None => format!("{}{}", c.label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
Some(why) => format!("{}\n{why}", tr_label),
None => format!("{}{}", tr_label, c.shortcut.map(|k| format!(" ({k})")).unwrap_or_default()),
};
if resp.on_hover_text(tip).clicked() && info.enabled {
let id = c.id;
@@ -399,7 +407,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}
// Panel label with drop-down.
let lr = Rect::from_min_size(pos2(px, top + 42.0), vec2(width, 18.0));
let label = panel.to_string();
let label = app.tr_label(panel).to_string();
let lresp = ui.interact(lr, ui.id().with(("panel", panel)), Sense::click());
crate::scenario::publish_handle(&format!("panel:{tab}:{panel}"), lr.center());
if lresp.hovered() && enabled_panel {
@@ -437,7 +445,10 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent);
let b = ui.add_enabled(info.enabled, egui::Button::new(c.label).frame(false).shortcut_text(c.shortcut.unwrap_or("")));
let b = ui.add_enabled(
info.enabled,
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
);
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
if b.secondary_clicked() {
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
+1 -1
View File
@@ -50,7 +50,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
+41
View File
@@ -0,0 +1,41 @@
# UI localization
SolveCraft embeds its translations and works offline. English and Japanese are currently
registered in `crates/ui-egui/src/i18n.rs`. Adding a language requires an embedded catalog and
one registration there; the preferences selector and validation use the same registry.
## Messages
`crates/ui-egui/src/locales/en.json` defines stable message IDs and English fallback text.
Keep IDs when changing wording. Other catalogs use the same IDs; missing messages fall back
to English. Use `app.tr("ui.preferences")` for static messages and
`Locale::format` for full sentences with named placeholders. Translate the entire sentence,
including punctuation, so a translator can reorder its arguments. Placeholder values are
inserted literally, even if a document name contains braces.
The English-label adapter (`app.tr_label`) supports existing engine and UI tables. It maps
display text to a message ID, then uses the same catalog lookup. New UI messages should use
stable IDs directly. The adapter does not change command IDs, model names, or scenario handles.
When a table's English wording changes, update the corresponding English catalog entry too.
Generated browser labels use named templates while their underlying model names stay intact.
Catalogs are parsed once and looked up through ordered maps. This is offline localization,
not a claim of zero runtime overhead. The catalog tests check registered locale IDs,
translation keys, and placeholder consistency. Run `cargo xtask ci` before submitting changes.
## Rendering and coverage
One immutable `Locale` type handles all translations. Table widgets without an app reference
use a render scope that restores the previous locale when it exits, including nested renders.
Standalone translated panels enter the same scope. Measure rendered text to size custom
widgets; UTF-8 byte lengths do not describe their displayed width.
The desktop host tries system CJK fonts and skips files that cannot be parsed. No system fonts
are redistributed. Japanese glyph coverage depends on installed fonts; browser hosts cannot
load local system fonts and must provide suitably licensed font assets separately.
The current messages support named substitution, not locale-specific plural selection,
number/date formatting, or right-to-left layout and shaping. Add those capabilities when
introducing messages or languages that need them; use an established message formatter for
plural rules instead of adding language branches to panels. The catalogs cover the UI surfaces
introduced by this PR, not every engine error or every newer command dialog.