Merge pull request #112 from Mithril-3d/feat/i18n-support
feat: add extensible offline localization with Japanese UI
This commit is contained in:
18 files changed
+1213
-84
No files matched your search
Generated
+1
@@ -3242,6 +3242,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solvecraft-engine",
|
||||
"ttf-parser",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user