From 7e24aa398a8c9978d002c61fc21b530d6fb62857 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Fri, 9 Oct 2026 13:08:28 +0530 Subject: [PATCH 1/3] CI: satisfy newer clippy lints (toolchain drift, behavior-preserving) Local clippy 1.96 flags pre-existing patterns the code has always had: manual range-contains comparisons, a dead `|| true` filter and a simplifiable boolean. None change behaviour; they only clear the lints so `cargo xtask ci` passes on a current toolchain. --- crates/core/src/date.rs | 2 +- crates/engine/src/cmd/file.rs | 2 +- crates/functions/src/math.rs | 2 +- crates/functions/src/stat.rs | 2 +- crates/functions/src/util.rs | 2 +- crates/numfmt/src/render.rs | 2 +- crates/ui-egui/src/formula_bar.rs | 2 +- crates/ui-egui/src/grid.rs | 9 ++------- 8 files changed, 9 insertions(+), 14 deletions(-) diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index 7fba23f..167522b 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -97,7 +97,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti /// Date/time from a serial. `None` for negative or too-large serials. pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option { - if !serial.is_finite() || serial < 0.0 || serial >= 2_958_466.0 { + if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) { return None; } let mut day = serial.floor() as i64; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 37a58dd..34b957c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -48,7 +48,7 @@ fn open(s: &mut Session, p: &Json) -> Result { }; let (wb, warnings) = crate::io::open_bytes(&name, &bytes)?; let title = std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string(); - let path = str_param(p, "path").map(str::to_string).filter(|p| crate::io::FileKind::from_path(p) == Some(crate::io::FileKind::Xlsx) || true); + let path = str_param(p, "path").map(str::to_string); // Replace an untouched blank Book1 like Excel does. if s.documents().len() == 1 && s.active().is_some_and(|d| d.path.is_none() && !d.is_dirty() && d.undo.is_empty() && d.wb.sheets.iter().all(|sh| sh.cells.is_empty())) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 3a0bab3..654395f 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -487,7 +487,7 @@ fn integer_args(a: &[Arg]) -> R> { v.iter() .map(|x| { let t = x.trunc(); - if t < 0.0 || t >= 9.007_199_254_740_992e15 { Err(CellError::Num) } else { Ok(t as u64) } + if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) } }) .collect() } diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index abdb4e7..51a5f32 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -1185,7 +1185,7 @@ fn binom_dist_range(a: &[Arg], _: C) -> R { let p = num(a, 1)?; let s1 = num(a, 2)?.trunc(); let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 }; - if n < 0.0 || n > 1e15 || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { + if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n { return Err(CellError::Num); } if s2 - s1 > 10_000.0 { diff --git a/crates/functions/src/util.rs b/crates/functions/src/util.rs index fca5dc4..fbd6649 100644 --- a/crates/functions/src/util.rs +++ b/crates/functions/src/util.rs @@ -51,7 +51,7 @@ pub(crate) fn arg(args: &[Arg], i: usize) -> R<&Arg> { /// Whether argument `i` was supplied (present and not an empty literal like `F(1,)`). pub(crate) fn has(args: &[Arg], i: usize) -> bool { - matches!(args.get(i), Some(a) if !(matches!(a.value, Value::Empty) && !a.from_ref)) + matches!(args.get(i), Some(a) if !matches!(a.value, Value::Empty) || a.from_ref) } /// Number coercion for a scalar parameter: empty = 0, TRUE = 1, numeric text parses. diff --git a/crates/numfmt/src/render.rs b/crates/numfmt/src/render.rs index 363dec1..79fe490 100644 --- a/crates/numfmt/src/render.rs +++ b/crates/numfmt/src/render.rs @@ -58,7 +58,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out { /// Renders a date/time. `None` when the serial is outside the supported range. pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option { - if !v.is_finite() || v < 0.0 || v >= 2_958_466.0 { + if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) { return None; } let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3); diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 753585f..48141c3 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -166,7 +166,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ed.caret_to_end = false; ed.sync_caret = true; } - if !ed.autocomplete.is_empty() && (tab || (enter && false)) { + if !ed.autocomplete.is_empty() && tab { ed.accept_autocomplete(); } else if enter { action = Some((if shift { -1 } else { 1 }, 0, false, false)); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 4b326cc..4faf43a 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -577,7 +577,7 @@ fn paint_quadrant( }; let indent = st.align.indent as f32 * 9.0 * z; let pad = 2.5 * z; - if st.align.wrap || st.align.h == HAlign::Justify || text.contains('\n') && st.align.wrap { + if st.align.wrap || st.align.h == HAlign::Justify { let wrap_w = (rect.width() - 2.0 * pad - indent).max(4.0); let mut job = egui::text::LayoutJob::simple(text.clone(), font.clone(), color, wrap_w); job.halign = match halign { @@ -1651,12 +1651,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo) { let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. - let table_range = sh - .tables - .iter() - .find(|t| t.range.start.row <= at.y as u32 || true) - .filter(|t| col >= t.range.start.col && col <= t.range.end.col) - .map(|t| t.range); + let table_range = sh.tables.first().filter(|t| col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range); if sh.autofilter.is_none() && let Some(r) = table_range { From e8e010b446cd7a35eff0ee64298c44b158a4e09c Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Fri, 9 Oct 2026 13:09:48 +0530 Subject: [PATCH 2/3] 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()); From d7c77e94cd161b97ce9d50db1d8c873871857df1 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 12:44:57 -0400 Subject: [PATCH 3/3] Review fix-up: locale-independent defaults, lenient saved language, listed commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UiState::default() is English: tests, the kittest harness and the snapshot example no longer depend on the host's locale. The desktop app alone starts from the system language, when ui.json names no supported language (first run, an older file, an unknown value). - An unknown or malformed saved language reads as English instead of failing the whole UiState and resetting every other preference. - app.language.set takes {"language": "ja"} (with `code` as an alias, the name the PR description used); the View combo now uses it via Language::code(). The app.language.* commands are listed by engine.commands with the other UI commands and documented in docs/control-protocol.md. - Home's text "Orientation" (方向) and Page Layout's print "Orientation" (印刷の向き) get separate keys through a `context|label` key form. - Japanese: Hide Rows → 行を表示しない, Trace Dependents → 参照先のトレース; translate the display-theme combo merged in from main. - system_theme test: the View tab now has two combo boxes; pick the theme one. Co-Authored-By: Claude Opus 5.5 --- apps/gridcraft/src/main.rs | 7 ++- crates/ui-egui/src/control.rs | 21 ++++---- crates/ui-egui/src/i18n.rs | 77 ++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 16 +++--- crates/ui-egui/src/ribbon.rs | 5 +- crates/ui-egui/tests/interaction.rs | 14 ++++- crates/ui-egui/tests/system_theme.rs | 3 +- docs/control-protocol.md | 6 +++ 8 files changed, 122 insertions(+), 27 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7343932..55dc9c2 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -79,11 +79,16 @@ fn load_prefs(app: &mut SheetApp) { if !prefs_enabled() { return; } + // First run (or a ui.json without a usable language): follow the desktop's language. + app.ui.language = gridcraft_ui_egui::i18n::Language::system(); let Some(dir) = config_dir() else { return }; if let Ok(b) = std::fs::read(dir.join("ui.json")) - && let Ok(ui) = serde_json::from_slice(&b) + && let Ok(ui) = serde_json::from_slice::(&b) { + let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v)); + let language = saved.unwrap_or(app.ui.language); app.ui = ui; + app.ui.language = language; } if let Ok(b) = std::fs::read(dir.join("prefs.json")) && let Ok(p) = serde_json::from_slice(&b) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 902ec25..b1733fd 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -155,16 +155,19 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); - for (id, label) in [ - ("ui.ribbonTab", "Ribbon Tab"), - ("view.formulaBar", "Formula Bar"), - ("view.collapseRibbon", "Collapse Ribbon"), - ("view.darkMode", "Dark Mode"), - ("view.theme", "Display Theme"), - ("view.zoom100", "100%"), - ("ui.dialog", "Open Dialog"), + for (id, label, params) in [ + ("ui.ribbonTab", "Ribbon Tab", ""), + ("view.formulaBar", "Formula Bar", ""), + ("view.collapseRibbon", "Collapse Ribbon", ""), + ("view.darkMode", "Dark Mode", ""), + ("view.theme", "Display Theme", ""), + ("view.zoom100", "100%", ""), + ("ui.dialog", "Open Dialog", ""), + ("app.language.set", "Interface Language", "{language: \"en\"|\"ja\"} (alias: code)"), + ("app.language.english", "Interface Language: English", ""), + ("app.language.japanese", "Interface Language: Japanese", ""), ] { - v.push(json!({"id": id, "label": label, "enabled": true, "ui": true})); + v.push(json!({"id": id, "label": label, "params": params, "enabled": true, "ui": true})); } ok(Json::Array(v)) } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 48e809d..18fda08 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -28,6 +28,14 @@ impl Language { } } + /// The language's code, as saved in `ui.json` and taken by `app.language.set`. + pub fn code(self) -> &'static str { + match self { + Self::En => "en", + Self::Ja => "ja", + } + } + pub fn parse(code: &str) -> Option { match code { "en" => Some(Self::En), @@ -56,16 +64,43 @@ impl Language { } } + /// Translates an interface label; untranslated labels fall back to English. + /// + /// A label that needs two translations (Home's text "Orientation" vs Page Layout's print + /// "Orientation") is keyed `context|label`: English shows only the part after the `|`. pub fn tr(self, text: &str) -> &str { if self == Self::Ja && let Some((_, japanese)) = JAPANESE.iter().find(|(english, _)| *english == text) { return japanese; } - text + text.rsplit_once('|').map_or(text, |(_, label)| label) } } +/// Deserializes a saved language leniently: an unknown or malformed value (a language removed in a +/// later version, a hand-edited `ui.json`) reads as English instead of failing the whole `UiState`, +/// which would reset every other preference with it. +pub fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Raw { + Code(String), + Other(serde::de::IgnoredAny), + } + Ok(match Raw::deserialize(d)? { + Raw::Code(code) => Language::parse(&code).unwrap_or_default(), + Raw::Other(_) => Language::default(), + }) +} + +/// The language a saved `ui.json` asks for, if it names a supported one. The desktop app uses it +/// to tell "saved English" from "nothing usable saved" (first run, an older file, an unknown +/// value), which starts from [`Language::system`] instead. +pub fn saved_language(ui_json: &serde_json::Value) -> Option { + ui_json.get("language").and_then(serde_json::Value::as_str).and_then(Language::parse) +} + /// 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 { @@ -121,7 +156,8 @@ const JAPANESE: &[(&str, &str)] = &[ ("Vertical Text", "縦書き"), ("Decrease Indent", "インデントを減らす"), ("Increase Indent", "インデントを増やす"), - ("Orientation", "印刷の向き"), + ("Orientation", "方向"), + ("Page Layout|Orientation", "印刷の向き"), ("Angle Counterclockwise", "左回りに回転"), ("Angle Clockwise", "右回りに回転"), ("Rotate Text Up", "文字列を上に回転"), @@ -153,7 +189,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Column Width…", "列の幅…"), ("AutoFit Column Width", "列の幅を自動調整"), ("Default Width…", "標準の幅…"), - ("Hide Rows", "行の表示/非表示"), + ("Hide Rows", "行を表示しない"), ("Unhide Rows", "再表示"), ("Hide Columns", "列の表示/非表示"), ("Unhide Columns", "再表示"), @@ -218,7 +254,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("Use in Formula", "数式で使用"), ("Create from Selection", "選択範囲から作成"), ("Trace Precedents", "参照元のトレース"), - ("Trace Dependents", "依存元のトレース"), + ("Trace Dependents", "参照先のトレース"), ("Remove Arrows", "矢印の解除"), ("Show Formulas", "数式の表示"), ("Error Checking", "エラーチェック"), @@ -274,6 +310,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Freeze First Column", "最左列の固定"), ("Unfreeze Panes", "ウィンドウ枠固定の解除"), ("Dark Mode", "ダーク モード"), + ("Display theme", "表示テーマ"), + ("System", "システム"), + ("Light", "ライト"), + ("Dark", "ダーク"), ("Interface language", "表示言語"), ("Command\nPalette", "コマンド パレット"), ("Agent\nControl", "エージェント制御"), @@ -355,13 +395,32 @@ mod tests { 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::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label)); } assert_eq!(Language::Ja.tr("Data"), "データ"); assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); + // A `context|label` key shows only its label in English and splits the translation. + assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); + assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); + assert_eq!(Language::Ja.tr("Orientation"), "方向"); assert_eq!(Language::parse("xx"), None); } + #[test] + fn unknown_saved_language_keeps_the_other_preferences() { + let ui: crate::UiState = serde_json::from_str(r#"{"dark": true, "language": "xx"}"#).unwrap(); + assert!(ui.dark); + assert_eq!(ui.language, Language::En); + let ui: crate::UiState = serde_json::from_str(r#"{"dark": true, "language": 7}"#).unwrap(); + assert!(ui.dark); + let ui: crate::UiState = serde_json::from_str(r#"{"language": "ja"}"#).unwrap(); + assert_eq!(ui.language, Language::Ja); + assert_eq!(crate::UiState::default().language, Language::En, "the default never reads the host locale"); + assert_eq!(saved_language(&serde_json::json!({"language": "ja"})), Some(Language::Ja)); + assert_eq!(saved_language(&serde_json::json!({"language": "xx"})), None); + assert_eq!(saved_language(&serde_json::json!({})), None); + } + #[test] fn tags_reduce_to_a_supported_language() { assert_eq!(Language::from_tag("ja-JP"), Some(Language::Ja)); @@ -378,5 +437,13 @@ mod tests { assert_eq!(restored.language, Language::Ja); app.run("app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); + app.run("app.language.set", serde_json::json!({"language": "ja"})).unwrap(); + assert_eq!(app.ui.language, Language::Ja); + app.run("app.language.set", serde_json::json!({"code": "en"})).unwrap(); + assert_eq!(app.ui.language, Language::En); + assert!(app.run("app.language.set", serde_json::json!({})).is_err(), "no language is an error, not a dialog"); + for l in Language::ALL { + assert_eq!(Language::parse(l.code()), Some(l)); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5e4ab5..4458090 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -45,8 +45,11 @@ 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")] + /// Interface language. English by default (so tests and headless renders never depend on the + /// host's locale); the desktop app starts from [`i18n::Language::system`] when `ui.json` has + /// no usable language (see [`i18n::saved_language`]). An unknown saved value reads as English + /// rather than failing the whole `UiState`. + #[serde(deserialize_with = "i18n::lenient")] pub language: i18n::Language, } @@ -61,7 +64,7 @@ impl Default for UiState { formula_bar_expanded: false, status_bar: true, recent: vec![], - language: i18n::Language::system(), + language: i18n::Language::En, } } } @@ -234,13 +237,14 @@ impl SheetApp { } "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 { + // `{"language": "ja"}`; `code` is accepted as an alias. + let code = p.get("language").or_else(|| p.get("code")).and_then(Json::as_str).unwrap_or(""); + match i18n::Language::parse(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(""))), + None => Err(format!("unknown language {code:?} (use \"en\" or \"ja\")")), } } "app.language.english" => { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 100bfc8..06bb02d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1246,7 +1246,7 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) { ], ); }); - let o = big_button(ui, Icon::Orient, "Orientation", "Orientation", true); + let o = big_button(ui, Icon::Orient, "Page Layout|Orientation", "Page Layout|Orientation", true); egui::Popup::menu(&o).show(|ui| { menu_items( app, @@ -1626,8 +1626,7 @@ fn view(app: &mut SheetApp, ui: &mut 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!({})); + act(app, "app.language.set", json!({"language": l.code()})); } } }, diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index ab33809..5baf10b 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -187,8 +187,18 @@ fn renders_every_ribbon_tab_without_panicking() { 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); + // English by default whatever the host's locale: only the desktop app reads the system language. + assert_eq!(h.state().ui.language, Language::En); + // The language commands are listed with the other UI commands. + let ctx = h.ctx.clone(); + let gridcraft_ui_egui::control::Outcome::Done(listed) = gridcraft_ui_egui::control::handle(h.state_mut(), &ctx, "engine.commands", &json!({})) + else { + panic!("engine.commands returns a response"); + }; + let ids: Vec<&str> = listed["result"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).collect(); + for id in ["app.language.set", "app.language.english", "app.language.japanese"] { + assert!(ids.contains(&id), "engine.commands lists {id}"); + } 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); diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/system_theme.rs index 2a5365a..0d2cdea 100644 --- a/crates/ui-egui/tests/system_theme.rs +++ b/crates/ui-egui/tests/system_theme.rs @@ -44,7 +44,8 @@ fn set_mode(h: &mut egui_kittest::Harness<'static, SheetApp>, mode: &str) { } fn select_ribbon_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { - h.get_by_role(egui::accesskit::Role::ComboBox).click(); + // The View tab's first combo box is the display theme (the interface language follows it). + h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("display theme combo").click(); h.run_steps(2); h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); h.run_steps(2); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9c6c31b..e465a07 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -45,5 +45,11 @@ System follows live OS appearance changes and falls back to Light when the platf The saved choice remains System; new users still start in Light. The legacy `view.darkMode` command and `ui.set {dark}` select a fixed Light or Dark theme. +`{command: "app.language.set", params: {language: "ja"}}` switches the interface language +(`code` is accepted as an alias for `language`); `app.language.english` and +`app.language.japanese` are shortcuts. Only interface chrome is translated, never command ids or +document text. The choice is saved in `ui.json`; with nothing saved the desktop app follows the +system language. + For a headless equivalent (no window), use `gridcraft-cli mcp` or `gridcraft-cli run`; see [`mcp.md`](mcp.md) and [`cli.md`](cli.md).