From e8e010b446cd7a35eff0ee64298c44b158a4e09c Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Fri, 9 Oct 2026 13:09:48 +0530 Subject: [PATCH] i18n: switch the interface language (issue #20) Add an interface-language preference. It defaults to the system's language on first run (sys-locale, native only) and is switched by hand from the View tab, next to Dark Mode, or with app.language.english / app.language.japanese over the control channel. It is saved in ui.json. Follows the filmcraft house convention: a Language enum and a table of English-to-Japanese strings with English fallback, so coverage can grow incrementally. Japanese chrome renders without any bundled font: the CJK fallback faces already appended to every family cover kana and kanji. The ribbon chrome all flows through the small_button/big_button/ toggle_button/split_button/icon_button helpers and menu_items, so translating there localises the whole ribbon in one place; the language of the frame is read from egui context data (i18n::current). Command ids, file names and document text are never translated. --- Cargo.lock | 10 + Cargo.toml | 2 + crates/ui-egui/Cargo.toml | 5 + crates/ui-egui/examples/snapshot.rs | 12 +- crates/ui-egui/src/i18n.rs | 382 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 25 ++ crates/ui-egui/src/ribbon.rs | 68 +++-- crates/ui-egui/src/tabs.rs | 8 +- crates/ui-egui/src/widgets.rs | 21 +- crates/ui-egui/tests/interaction.rs | 16 ++ 10 files changed, 517 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/i18n.rs diff --git a/Cargo.lock b/Cargo.lock index 29bd97a..fd6140b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1528,6 +1528,7 @@ dependencies = [ "log", "serde", "serde_json", + "sys-locale", "toml", ] @@ -3187,6 +3188,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/Cargo.toml b/Cargo.toml index 89f268e..9c0c145 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" arboard = { version = "3.6", default-features = false } +# The desktop's preferred language, for the first-run interface language (native only). +sys-locale = "0.3" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 01d7c00..ee77578 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,11 @@ image = { workspace = true } [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# The desktop's preferred language, for the first-run interface language. The web build has no +# system locale, so the crate is native-only (see i18n::Language::system). +sys-locale = { workspace = true } + [build-dependencies] serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index 28fba9e..f384d18 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -1,7 +1,7 @@ //! Renders the whole GridCraft window offscreen (wgpu, no window) to a PNG. //! //! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx] -//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] +//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language ja] //! [--dialog 'name={json}'] out.png` use gridcraft_engine::Session; @@ -20,6 +20,7 @@ fn main() { let mut pane: Option = None; let mut chart: Option = None; let mut dialog: Option = None; + let mut language: Option = None; let mut out = "snapshot.png".to_string(); let mut i = 0; while i < args.len() { @@ -65,6 +66,10 @@ fn main() { i += 1; dialog = args.get(i).cloned(); } + "--language" => { + i += 1; + language = args.get(i).cloned(); + } other => out = other.to_string(), } i += 1; @@ -93,6 +98,11 @@ fn main() { { let mut app = SheetApp::new(session, Default::default()); app.ui.dark = dark; + if let Some(code) = &language + && let Some(l) = gridcraft_ui_egui::i18n::Language::parse(code) + { + app.ui.language = l; + } if let Some(t) = tab { app.ui.ribbon_tab = t; } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 0000000..48e809d --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,382 @@ +//! Interface translations. Command ids and document text stay stable; only interface chrome is +//! translated. Untranslated labels fall back to English, so coverage can grow incrementally. +//! +//! The interface language is a preference ([`crate::UiState::language`]): on first run it defaults +//! to the system's language ([`Language::system`]), and the View tab switches it by hand. +//! +//! No Japanese font is bundled and none is installed here: the CJK faces [`crate::theme`] appends +//! to every family already cover kana and kanji, so Japanese interface text renders without help. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Language { + #[default] + En, + Ja, +} + +impl Language { + pub const ALL: [Self; 2] = [Self::En, Self::Ja]; + + /// The language's own name, shown in the switcher. + pub fn name(self) -> &'static str { + match self { + Self::En => "English", + Self::Ja => "日本語", + } + } + + pub fn parse(code: &str) -> Option { + match code { + "en" => Some(Self::En), + "ja" => Some(Self::Ja), + _ => None, + } + } + + /// A language tag (`ja`, `ja-JP`, `en_US`) reduced to a supported language. Native only: the web + /// build has no system locale to read and tests exercise it on the host. + #[cfg(not(target_arch = "wasm32"))] + fn from_tag(tag: &str) -> Option { + Self::parse(&tag.split(['-', '_']).next().unwrap_or("").to_ascii_lowercase()) + } + + /// The system's preferred interface language, best effort; English when it is unknown or + /// unsupported. Used as the first-run default while nothing is saved. + pub fn system() -> Self { + #[cfg(not(target_arch = "wasm32"))] + { + sys_locale::get_locale().and_then(|tag| Self::from_tag(&tag)).unwrap_or(Self::En) + } + #[cfg(target_arch = "wasm32")] + { + Self::En + } + } + + pub fn tr(self, text: &str) -> &str { + if self == Self::Ja + && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) + { + return japanese; + } + text + } +} + +/// egui context-data key holding the language of the frame being drawn. Widgets that only get a +/// `Ui` (not the app) read it to translate their labels; [`set_current`] writes it once per frame. +fn lang_key() -> egui::Id { + egui::Id::new("gridcraft.language") +} + +/// The language the current frame is drawing in. `En` when unset. +pub fn current(ctx: &egui::Context) -> Language { + ctx.data(|d| d.get_temp(lang_key())).unwrap_or_default() +} + +/// Sets the language for the current frame; call once per frame before anything is drawn. +pub fn set_current(ctx: &egui::Context, language: Language) { + ctx.data_mut(|d| d.insert_temp(lang_key(), language)); +} + +/// English label → Japanese. Keys are the exact interface strings (command ids, file names and +/// document text are never translated); anything absent falls back to English. +const JAPANESE: &[(&str, &str)] = &[ + ("Paste", "貼り付け"), + ("Paste Values", "値の貼り付け"), + ("Paste Formulas", "数式の貼り付け"), + ("Paste Formatting", "書式の貼り付け"), + ("Transpose", "行列を入れ替え"), + ("Paste Link", "リンクの貼り付け"), + ("Paste Special…", "形式を選択して貼り付け…"), + ("Cut (⌘X)", "切り取り (⌘X)"), + ("Copy (⌘C)", "コピー (⌘C)"), + ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), + ("Bold (⌘B)", "太字 (⌘B)"), + ("Italic (⌘I)", "斜体 (⌘I)"), + ("Underline (⌘U)", "下線 (⌘U)"), + ("Double Underline", "二重下線"), + ("Strikethrough", "取り消し線"), + ("Underline", "下線"), + ("Increase Font Size", "フォントの拡大"), + ("Decrease Font Size", "フォントの縮小"), + ("Wrap Text", "折り返して全体を表示"), + ("Merge & Center", "セルを結合して中央揃え"), + ("Merge Cells", "セルの結合"), + ("Merge Across", "横方向に結合"), + ("Unmerge Cells", "セル結合の解除"), + ("Fill Color", "塗りつぶしの色"), + ("Font Color", "フォントの色"), + ("Borders", "罫線"), + ("Top\nAlign", "上揃え"), + ("Middle\nAlign", "中央揃え (縦)"), + ("Bottom\nAlign", "下揃え"), + ("Left", "左"), + ("Right", "右"), + ("Center", "中央"), + ("Horizontal", "水平"), + ("Vertical Text", "縦書き"), + ("Decrease Indent", "インデントを減らす"), + ("Increase Indent", "インデントを増やす"), + ("Orientation", "印刷の向き"), + ("Angle Counterclockwise", "左回りに回転"), + ("Angle Clockwise", "右回りに回転"), + ("Rotate Text Up", "文字列を上に回転"), + ("Rotate Text Down", "文字列を下に回転"), + ("Accounting Number Format", "通貨表示形式"), + ("Percent Style", "パーセント表示"), + ("Comma Style", "桁区切りスタイル"), + ("Increase Decimal", "小数点以下の桁数を増やす"), + ("Decrease Decimal", "小数点以下の桁数を減らす"), + ("More Accounting Formats…", "その他の通貨表示形式…"), + ("Conditional\nFormatting", "条件付き書式"), + ("Conditional Formatting", "条件付き書式"), + ("Format\nas Table", "テーブルとして書式設定"), + ("Cell\nStyles", "セルのスタイル"), + ("Format Cells…", "セルの書式設定…"), + ("Format Cell Alignment…", "セルの配置…"), + ("Format\nPane", "書式ウィンドウ"), + ("Format", "書式"), + ("Insert", "挿入"), + ("Insert Cells…", "セルの挿入…"), + ("Delete", "削除"), + ("Delete Cells…", "セルの削除…"), + ("Insert Sheet Rows", "シートの行を挿入"), + ("Insert Sheet Columns", "シートの列を挿入"), + ("Delete Sheet Rows", "シートの行を削除"), + ("Delete Sheet Columns", "シートの列を削除"), + ("Row Height…", "行の高さ…"), + ("AutoFit Row Height", "行の高さを自動調整"), + ("Column Width…", "列の幅…"), + ("AutoFit Column Width", "列の幅を自動調整"), + ("Default Width…", "標準の幅…"), + ("Hide Rows", "行の表示/非表示"), + ("Unhide Rows", "再表示"), + ("Hide Columns", "列の表示/非表示"), + ("Unhide Columns", "再表示"), + ("Lock Cell", "セルのロック"), + ("Clear", "クリア"), + ("Clear All", "すべてクリア"), + ("Clear Formats", "書式のクリア"), + ("Clear Contents", "数式と値のクリア"), + ("Clear Comments and Notes", "コメントとメモのクリア"), + ("Clear Hyperlinks", "ハイパーリンクのクリア"), + ("AutoSum", "オート SUM"), + ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), + ("Flash\nFill", "フラッシュ フィル"), + ("Flash Fill", "フラッシュ フィル"), + ("Fill", "フィル"), + ("Clear Print Area", "印刷範囲のクリア"), + ("Find &\nSelect", "検索と選択"), + ("Find…", "検索…"), + ("Replace…", "置換…"), + ("Go To…", "ジャンプ…"), + ("Go To Special…", "セルを選択…"), + ("PivotTable", "ピボットテーブル"), + ("Recommended\nCharts", "推奨グラフ"), + ("Table", "テーブル"), + ("Pictures", "画像"), + ("Shapes", "図形"), + ("Icons", "アイコン"), + ("Text\nBox", "テキスト ボックス"), + ("Comment", "コメント"), + ("New\nComment", "新しいコメント"), + ("New Note", "新しいメモ"), + ("Show/Hide Note", "メモの表示/非表示"), + ("Header &\nFooter", "ヘッダーとフッター"), + ("Text to\nColumns", "区切り位置"), + ("Link", "リンク"), + ("Symbol", "記号"), + ("Sparklines", "スパークライン"), + ("Header & Footer", "ヘッダーとフッター"), + ("Draw with ink", "インクで描画"), + ("Eraser", "消しゴム"), + ("Ink to\nShape", "インクを図形に変換"), + ("Select\nObjects", "オブジェクトの選択"), + ("Themes", "テーマ"), + ("Margins", "余白"), + ("Size", "サイズ"), + ("Print\nArea", "印刷範囲"), + ("Set Print Area", "印刷範囲の設定"), + ("Breaks", "改ページ"), + ("Insert Page Break", "改ページの挿入"), + ("Remove Page Break", "改ページの解除"), + ("Reset All Page Breaks", "すべての改ページを解除"), + ("Print\nTitles", "印刷タイトル"), + ("Narrow", "狭い"), + ("Wide", "広い"), + ("Normal", "標準"), + ("Portrait", "縦"), + ("Landscape", "横"), + ("Custom Margins…", "ユーザー設定の余白…"), + ("Insert\nFunction", "関数の挿入"), + ("Name\nManager", "名前の管理"), + ("Define Name", "名前の定義"), + ("Use in Formula", "数式で使用"), + ("Create from Selection", "選択範囲から作成"), + ("Trace Precedents", "参照元のトレース"), + ("Trace Dependents", "依存元のトレース"), + ("Remove Arrows", "矢印の解除"), + ("Show Formulas", "数式の表示"), + ("Error Checking", "エラーチェック"), + ("Evaluate Formula", "数式の検証"), + ("Watch Window", "ウォッチ ウィンドウ"), + ("Calculation\nOptions", "計算方法の設定"), + ("Calculate Now", "再計算実行"), + ("Calculate Sheet", "シートの再計算"), + ("Automatic", "自動"), + ("Automatic Except for Data Tables", "データ テーブル以外は自動"), + ("Manual", "手動"), + ("Sort &\nFilter", "並べ替えとフィルター"), + ("Sort", "並べ替え"), + ("Sort A to Z", "昇順"), + ("Sort Z to A", "降順"), + ("Custom Sort…", "ユーザー設定の並べ替え…"), + ("Filter", "フィルター"), + ("Reapply", "再適用"), + ("Get Data\n(Text/CSV)", "データの取得 (テキスト/CSV)"), + ("Remove\nDuplicates", "重複の削除"), + ("Data\nValidation", "データの入力規則"), + ("Data Validation", "データの入力規則"), + ("Data Validation…", "データの入力規則…"), + ("Circle Invalid Data", "無効なデータをマーク"), + ("Clear Validation", "入力規則のクリア"), + ("Group", "グループ化"), + ("Ungroup", "グループ解除"), + ("Subtotal", "小計"), + ("What-If\nAnalysis", "What-If 分析"), + ("Data Table…", "データ テーブル…"), + ("Scenario Manager…", "シナリオ マネージャー…"), + ("Goal Seek…", "ゴール シーク…"), + ("Spelling", "スペルチェック"), + ("Check\nAccessibility", "アクセシビリティ チェック"), + ("Threaded Comments", "スレッド化されたコメント"), + ("Show\nComments", "コメントの表示"), + ("Protect\nWorkbook", "ブックの保護"), + ("Unprotect\nSheet", "シート保護の解除"), + ("Protect Sheet…", "シートの保護…"), + ("Comments", "コメント"), + ("Notes", "メモ"), + ("Page Break\nPreview", "改ページ プレビュー"), + ("Page\nLayout", "ページ レイアウト"), + ("Formula Bar", "数式バー"), + ("Gridlines", "目盛線"), + ("Headings", "見出し"), + ("Zoom", "ズーム"), + ("100%", "100%"), + ("Zoom to\nSelection", "選択範囲を拡大"), + ("Freeze\nPanes", "ウィンドウ枠の固定"), + ("Freeze Panes", "ウィンドウ枠の固定"), + ("Freeze Top Row", "最上行の固定"), + ("Freeze First Column", "最左列の固定"), + ("Unfreeze Panes", "ウィンドウ枠固定の解除"), + ("Dark Mode", "ダーク モード"), + ("Interface language", "表示言語"), + ("Command\nPalette", "コマンド パレット"), + ("Agent\nControl", "エージェント制御"), + ("Action\nJournal", "操作ジャーナル"), + ("About\nGridCraft", "GridCraft について"), + ("Share", "共有"), + ("Add Chart\nElement", "グラフ要素を追加"), + ("Change\nChart Type", "グラフの種類の変更"), + ("Switch\nRow/Column", "行/列の切り替え"), + ("Data Labels: None", "データ ラベル: なし"), + ("Data Labels: Show", "データ ラベル: 表示"), + ("Legend: None", "凡例: なし"), + ("Legend: Bottom", "凡例: 下"), + ("Legend: Top", "凡例: 上"), + ("Legend: Right", "凡例: 右"), + ("Gridlines: None", "目盛線: なし"), + ("Gridlines: Show", "目盛線: 表示"), + ("Chart\nTitle", "グラフ タイトル"), + ("Delete\nChart", "グラフの削除"), + ("Convert\nto Range", "範囲に変換"), + ("Table\nStyles", "テーブル スタイル"), + ("Top 10 Items", "上位 10 項目"), + ("Top 10%", "上位 10%"), + ("Bottom 10 Items", "下位 10 項目"), + ("Bottom 10%", "下位 10%"), + ("Above Average", "平均より上"), + ("Below Average", "平均より下"), + ("Clear Rules from Entire Sheet", "シート全体からルールをクリア"), + ("Clear Rules from Selected Cells", "選択したセルからルールをクリア"), + ("Constants", "定数"), + ("Data Bars", "データ バー"), + ("Color Scales", "カラー スケール"), + ("Icon Sets", "アイコン セット"), + ("Home", "ホーム"), + ("Draw", "描画"), + ("Page Layout", "ページ レイアウト"), + ("Formulas", "数式"), + ("Data", "データ"), + ("Review", "レビュー"), + ("View", "表示"), + ("Automate", "自動化"), + ("Table Design", "テーブル デザイン"), + ("Chart Design", "グラフ デザイン"), + ("Ready", "準備完了"), + ("Enter", "入力"), + ("Edit", "編集"), + ("Point", "ポイント"), + ("Calculate", "計算"), + ("Select destination and press Enter or choose Paste", "貼り付け先を選び、Enter キーを押すか [貼り付け] を選びます"), + ("General", "標準"), + ("Number", "数値"), + ("Currency", "通貨"), + ("Accounting", "会計"), + ("Date", "日付"), + ("Time", "時刻"), + ("Percentage", "パーセンテージ"), + ("Fraction", "分数"), + ("Scientific", "指数"), + ("Text", "文字列"), + ("Special", "特殊"), + ("Custom", "ユーザー設定"), + ("Short Date", "日付"), + ("Long Date", "長い日付"), + ("More Number Formats…", "その他の表示形式…"), + ("Financial", "財務"), + ("Logical", "論理"), + ("Date &\nTime", "日付/時刻"), + ("Lookup &\nReference", "検索/行列"), + ("Math &\nTrig", "数学/三角"), + ("More\nFunctions", "その他の関数"), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn translations_are_unique_and_preserve_unknown_text() { + for (i, (en, ja)) in JAPANESE.iter().enumerate() { + assert!(!ja.is_empty()); + assert!(JAPANESE.iter().take(i).all(|(other, _)| en != other)); + assert_eq!(Language::En.tr(en), *en); + } + assert_eq!(Language::Ja.tr("Data"), "データ"); + assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(Language::parse("xx"), None); + } + + #[test] + fn tags_reduce_to_a_supported_language() { + assert_eq!(Language::from_tag("ja-JP"), Some(Language::Ja)); + assert_eq!(Language::from_tag("en_US"), Some(Language::En)); + assert_eq!(Language::from_tag("de-DE"), None); + } + + #[test] + fn language_commands_switch_and_persist() { + let mut app = crate::SheetApp::new(gridcraft_engine::Session::default(), Default::default()); + app.run("app.language.japanese", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::Ja); + let restored: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Ja); + app.run("app.language.english", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Language::En); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8eb1eca..4e7a383 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod dialogs; pub mod editor; pub mod formula_bar; pub mod grid; +pub mod i18n; pub mod icons; pub mod panes; pub mod pivot_pane; @@ -41,6 +42,9 @@ pub struct UiState { pub formula_bar_expanded: bool, pub status_bar: bool, pub recent: Vec, + /// Interface language. Absent in older `ui.json` → the system's language (English fallback). + #[serde(default = "i18n::Language::system")] + pub language: i18n::Language, } impl Default for UiState { @@ -53,6 +57,7 @@ impl Default for UiState { formula_bar_expanded: false, status_bar: true, recent: vec![], + language: i18n::Language::system(), } } } @@ -187,6 +192,24 @@ impl SheetApp { Ok(json!({"on": self.ui.dark})) } "view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), + "app.language.set" => { + let code = p.get("language").and_then(Json::as_str).and_then(i18n::Language::parse); + match code { + Some(l) => { + self.ui.language = l; + Ok(json!({"language": l})) + } + None => Err(format!("unknown language {:?} (use \"en\" or \"ja\")", p.get("language").and_then(Json::as_str).unwrap_or(""))), + } + } + "app.language.english" => { + self.ui.language = i18n::Language::En; + Ok(json!({"language": i18n::Language::En})) + } + "app.language.japanese" => { + self.ui.language = i18n::Language::Ja; + Ok(json!({"language": i18n::Language::Ja})) + } "ui.dialog" => { let name = p.get("name").and_then(Json::as_str).unwrap_or(""); self.open_dialog(name, p.clone()); @@ -399,6 +422,8 @@ impl SheetApp { return; } let t0 = now_ms(); + // The language the widgets translate with this frame (see `i18n::current`). + i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); ribbon::title_bar(self, ui); ribbon::show(self, ui); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 47c5432..52bc34b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -139,7 +139,8 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let active = app.ui.ribbon_tab == tab; let contextual = ctx_tabs.contains(&tab); let font = theme::ui_font(13.5); - let tw = ui.painter().layout_no_wrap(tab.to_string(), font.clone(), t.text).size().x; + let label = app.ui.language.tr(tab); + let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x; let (r, resp) = ui.allocate_exact_size(vec2(tw + 22.0, 30.0), Sense::click()); if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(2.0, 4.0)), 5.0, t.hover); @@ -151,7 +152,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { } else { t.text_dim }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { theme::ui_bold(13.5) } else { font }, color); + ui.painter().text(r.center(), Align2::CENTER_CENTER, label, if active { theme::ui_bold(13.5) } else { font }, color); if active { let ul = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.clamp(20.0, 40.0), 3.0)); ui.painter().rect_filled(ul, 1.5, t.accent); @@ -248,7 +249,8 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: ui.separator(); continue; } - if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))).clicked() { + let lang = app.ui.language; + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))).clicked() { if let Some(d) = id.strip_prefix("dialog:") { app.open_dialog(d, p.clone()); } else { @@ -445,7 +447,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if wrap.clicked() { act(app, "home.wrapText", json!({})); } - ui.label(egui::RichText::new("Wrap Text").font(theme::ui_font(12.5))); + ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5))); }); ui.horizontal(|ui| { for (icon, h, id, tip) in [ @@ -467,7 +469,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if m { act(app, "home.mergeCenter", json!({})); } - ui.label(egui::RichText::new("Merge & Center").font(theme::ui_font(12.5))); + ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5))); egui::Popup::menu(&ma).show(|ui| { menu_items( app, @@ -736,27 +738,28 @@ fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let code = st.num_fmt.as_str().to_string(); let kind = gridcraft_engine::display::number_format(&code).kind(); let current = format!("{kind:?}"); + let lang = app.ui.language; let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default(); let mut picked: Option<&str> = None; - egui::ComboBox::from_id_salt("number_format").width(150.0).selected_text(egui::RichText::new(¤t).font(theme::ui_font(12.5))).show_ui( - ui, - |ui| { + egui::ComboBox::from_id_salt("number_format") + .width(150.0) + .selected_text(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { for name in ["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"] { let code = gridcraft_engine::cmd::format::format_code_for(name); let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default(); - let r = ui.add(egui::Button::selectable(current == name, format!("{name:<12} {preview}")).min_size(vec2(240.0, 22.0))); + let r = ui.add(egui::Button::selectable(current == name, format!("{:<12} {preview}", lang.tr(name))).min_size(vec2(240.0, 22.0))); if r.clicked() { picked = Some(name); } } ui.separator(); - if ui.button("More Number Formats…").clicked() { + if ui.button(lang.tr("More Number Formats…")).clicked() { picked = Some("__more"); } - }, - ); + }); match picked { Some("__more") => app.open_dialog("formatCells", json!({"tab": "Number"})), Some(n) => act(app, "home.numberFormat", json!({"format": n})), @@ -1263,25 +1266,28 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { .active() .and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings))) .unwrap_or((true, true, false, false)); + // These two checkboxes mean the screen view vs. the printed page, so they do not share the + // "View" tab label; pick per language rather than through the shared table. + let (screen, print) = if app.ui.language == crate::i18n::Language::Ja { ("画面", "印刷") } else { ("View", "Print") }; ui.vertical(|ui| { - ui.label(egui::RichText::new("Gridlines").strong().small()); + ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small()); let mut v = sh.0; - if ui.checkbox(&mut v, "View").changed() { + if ui.checkbox(&mut v, screen).changed() { act(app, "view.gridlines", json!({"on": v})); } let mut p = sh.2; - if ui.checkbox(&mut p, "Print").changed() { + if ui.checkbox(&mut p, print).changed() { act(app, "pageLayout.printGridlines", json!({"on": p})); } }); ui.vertical(|ui| { - ui.label(egui::RichText::new("Headings").strong().small()); + ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small()); let mut v = sh.1; - if ui.checkbox(&mut v, "View").changed() { + if ui.checkbox(&mut v, screen).changed() { act(app, "view.headings", json!({"on": v})); } let mut p = sh.3; - if ui.checkbox(&mut p, "Print").changed() { + if ui.checkbox(&mut p, print).changed() { act(app, "pageLayout.printHeadings", json!({"on": p})); } }); @@ -1526,17 +1532,18 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { } sep(ui); let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true)); + let lang = app.ui.language; ui.vertical(|ui| { let mut fb = app.ui.formula_bar; - if ui.checkbox(&mut fb, "Formula Bar").changed() { + if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() { app.ui.formula_bar = fb; } let mut g = s.0; - if ui.checkbox(&mut g, "Gridlines").changed() { + if ui.checkbox(&mut g, lang.tr("Gridlines")).changed() { act(app, "view.gridlines", json!({"on": g})); } let mut h = s.1; - if ui.checkbox(&mut h, "Headings").changed() { + if ui.checkbox(&mut h, lang.tr("Headings")).changed() { act(app, "view.headings", json!({"on": h})); } }); @@ -1567,10 +1574,27 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { }); let mut dark = app.ui.dark; ui.vertical(|ui| { - if ui.checkbox(&mut dark, "Dark Mode").changed() { + if ui.checkbox(&mut dark, lang.tr("Dark Mode")).changed() { app.ui.dark = dark; } }); + sep(ui); + // Interface language: settings live in the View tab, like Excel's Options. The label follows + // the current language so it stays readable; the choices are always shown in their own script. + ui.vertical(|ui| { + ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); + egui::ComboBox::from_id_salt("ui_language").width(120.0).selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))).show_ui( + ui, + |ui| { + for l in crate::i18n::Language::ALL { + if ui.selectable_label(l == lang, l.name()).clicked() { + let code = if l == crate::i18n::Language::Ja { "app.language.japanese" } else { "app.language.english" }; + act(app, code, json!({})); + } + } + }, + ); + }); } fn automate(app: &mut SheetApp, ui: &mut Ui) { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 0258169..e4a4068 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -190,11 +190,11 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { gridcraft_engine::Mode::Edit => "Edit", gridcraft_engine::Mode::Point => "Point", }; - ui.label(egui::RichText::new(mode).font(theme::ui_font(12.5)).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr(mode)).font(theme::ui_font(12.5)).color(t.text_dim)); if let Some(d) = app.session.active() { if d.wb.calc.mode == gridcraft_engine::model::CalcMode::Manual { ui.add_space(12.0); - ui.label(egui::RichText::new("Calculate").font(theme::ui_font(12.5)).color(t.text_dim)); + ui.label(egui::RichText::new(app.ui.language.tr("Calculate")).font(theme::ui_font(12.5)).color(t.text_dim)); } if !d.calc.circular.is_empty() { ui.add_space(12.0); @@ -205,7 +205,9 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { if app.session.clipboard.is_some() && app.editor.is_none() { ui.add_space(12.0); ui.label( - egui::RichText::new("Select destination and press Enter or choose Paste").font(theme::ui_font(12.5)).color(t.text_dim), + egui::RichText::new(app.ui.language.tr("Select destination and press Enter or choose Paste")) + .font(theme::ui_font(12.5)) + .color(t.text_dim), ); } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 139256f..058eca9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -4,9 +4,16 @@ use egui::{Align2, Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use crate::SheetApp; +use crate::i18n; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens}; +/// Translates a label or tooltip for the language of the frame being drawn (English if untranslated). +/// Ribbon chrome all flows through these widgets, so translating here localises it in one place. +fn tr<'a>(ui: &Ui, text: &'a str) -> &'a str { + i18n::current(ui.ctx()).tr(text) +} + /// A flat square icon button with hover highlight and tooltip. pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egui::Vec2) -> Response { let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); @@ -17,7 +24,7 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, color); - resp.on_hover_text(tip) + resp.on_hover_text(tr(ui, tip)) } /// A toggle-able small button (e.g. Bold) showing a checked state. @@ -31,13 +38,14 @@ pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { ui.painter().rect_filled(rect, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), icon, t.text); - resp.on_hover_text(tip) + resp.on_hover_text(tr(ui, tip)) } /// Large ribbon button: 32px icon over a one- or two-line label. pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(11.5); + let label = tr(ui, label); let lines: Vec<&str> = label.split('\n').collect(); let text_w = lines.iter().map(|l| ui.painter().layout_no_wrap(l.to_string(), font.clone(), t.text).size().x).fold(0.0, f32::max); let w = (text_w + 12.0).max(44.0); @@ -49,19 +57,20 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo } icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.center().x, rect.top() + 21.0), vec2(30.0, 30.0)), icon, t.text); for (i, l) in lines.iter().enumerate() { - let mut s = l.to_string(); + let mut s = (*l).to_string(); if dropdown && i + 1 == lines.len() { s.push_str(" ▾"); } ui.painter().text(pos2(rect.center().x, rect.top() + 46.0 + i as f32 * 13.0), Align2::CENTER_CENTER, s, font.clone(), t.text); } - resp.on_hover_text(tip) + resp.on_hover_text(tr(ui, tip)) } /// Small ribbon button: 16px icon with optional label to the right. pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(12.5); + let label = tr(ui, label); let tw = if label.is_empty() { 0.0 } else { ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x + 6.0 }; let w = 24.0 + tw + if dropdown { 12.0 } else { 0.0 }; let (rect, resp) = ui.allocate_exact_size(vec2(w, 23.0), Sense::click()); @@ -82,7 +91,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: b t.text_dim, ); } - resp.on_hover_text(tip) + resp.on_hover_text(tr(ui, tip)) } /// A split button: main part runs the default action, the arrow opens a menu. @@ -103,7 +112,7 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) ui.painter().rect_filled(Rect::from_min_size(pos2(main.left() + 4.0, main.bottom() - 5.0), vec2(17.0, 3.0)), 0.0, c); } icons::paint(ui.painter(), Rect::from_center_size(arrow.center(), vec2(10.0, 10.0)), Icon::Chevron, t.text_dim); - (m.on_hover_text(tip).clicked(), a) + (m.on_hover_text(tr(ui, tip)).clicked(), a) } /// Our colour palette (theme row + tints + standard colours), returns a picked hex colour, diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index 74bbeb4..63cdc81 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -98,6 +98,22 @@ fn renders_every_ribbon_tab_without_panicking() { h.run_steps(3); } +#[test] +fn language_switch_translates_the_ribbon_and_persists() { + use gridcraft_ui_egui::i18n::Language; + let mut h = harness(blank()); + // English by default on a machine whose system language is not Japanese. + assert_ne!(h.state().ui.language, Language::Ja); + h.state_mut().run("app.language.japanese", json!({})).unwrap(); + h.run_steps(2); // the ribbon renders the Japanese tabs without panicking + assert_eq!(h.state().ui.language, Language::Ja); + // The preference round-trips through the saved UiState. + let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); + assert_eq!(restored.language, Language::Ja); + h.state_mut().run("app.language.english", json!({})).unwrap(); + assert_eq!(h.state().ui.language, Language::En); +} + #[test] fn dialogs_open_and_close() { let mut h = harness(blank());