diff --git a/Cargo.lock b/Cargo.lock index 9c720b9..ade23a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1543,6 +1543,7 @@ dependencies = [ "log", "serde", "serde_json", + "sys-locale", "toml", ] @@ -3241,6 +3242,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 a1ce6a5..2e0e895 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/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 1c7233a..6f91741 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/core/src/date.rs b/crates/core/src/date.rs index b824434..81fa6c5 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -105,7 +105,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/functions/src/math.rs b/crates/functions/src/math.rs index 8196f5a..4d9eb45 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 5296c48..14c6279 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/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/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/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index dace534..826c996 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -169,7 +169,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/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 0000000..18fda08 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,449 @@ +//! 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 => "日本語", + } + } + + /// 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), + "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 + } + } + + /// 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.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 { + 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", "方向"), + ("Page Layout|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", "ダーク モード"), + ("Display theme", "表示テーマ"), + ("System", "システム"), + ("Light", "ライト"), + ("Dark", "ダーク"), + ("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.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)); + 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); + 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 c3efe3b..4422f1f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod dialogs; pub mod editor; pub mod formula_bar; pub mod grid; +pub mod i18n; pub mod icons; pub mod keytips; pub mod panes; @@ -46,6 +47,12 @@ pub struct UiState { pub formula_bar_expanded: bool, pub status_bar: bool, pub recent: Vec, + /// 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, } impl Default for UiState { @@ -59,6 +66,7 @@ impl Default for UiState { formula_bar_expanded: false, status_bar: true, recent: vec![], + language: i18n::Language::En, } } } @@ -278,6 +286,25 @@ impl SheetApp { Ok(json!({"mode": self.ui.theme_mode()})) } "view.zoom100" => return Some(self.session.run("view.zoom", json!({"percent": 100})).inspect(|_| self.after_engine())), + "app.language.set" => { + // `{"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 {code:?} (use \"en\" or \"ja\")")), + } + } + "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()); @@ -502,6 +529,8 @@ impl SheetApp { } let t0 = now_ms(); text_box::before_ui(self, &ctx); + // The language the widgets translate with this frame (see `i18n::current`). + i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); self.ribbon_keys(&ctx); ribbon::title_bar(self, ui); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 750d87d..5c0c8f5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -140,7 +140,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); @@ -152,7 +153,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); @@ -290,7 +291,8 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: ui.separator(); continue; } - let response = ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))); + let lang = app.ui.language; + let response = ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))); let sequence = match (app.keytips.prefix(), *id) { (Some("HV"), "dialog:pasteSpecial") => Some("HVS"), (Some("E"), "dialog:pasteSpecial") => Some("ES"), @@ -518,7 +520,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 [ @@ -549,7 +551,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, @@ -828,27 +830,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})), @@ -1307,7 +1310,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, @@ -1355,25 +1358,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})); } }); @@ -1618,17 +1624,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})); } }); @@ -1658,21 +1665,37 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - ui.label("Display theme"); + ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); let label = match mode { "system" => "System", "dark" => "Dark", _ => "Light", }; - egui::ComboBox::from_id_salt("display_theme").selected_text(label).width(88.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("display_theme").selected_text(lang.tr(label)).width(88.0).show_ui(ui, |ui| { for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, label).clicked() { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { act(app, "view.theme", json!({"mode": value})); } } }); }); + 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() { + act(app, "app.language.set", json!({"language": l.code()})); + } + } + }, + ); + }); } 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 f1b39fc..18649e6 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<_> = label.split('\n').map(|line| ui.painter().layout_no_wrap(line.to_string(), font.clone(), t.text)).collect(); let arrow_size = 10.0; let arrow_space = if dropdown { 4.0 + arrow_size } else { 0.0 }; @@ -65,13 +73,14 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str, dropdown: boo ); } } - 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()); @@ -92,7 +101,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. @@ -113,7 +122,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 1cd0a8a..5baf10b 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -183,6 +183,32 @@ 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 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); + // 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()); 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).