Merge pull request #31 from justinjohn0306/feat/i18n-language

i18n: switch the interface language, defaulting to the system language (#20)
This commit is contained in:
Brandon Thomas 2026-10-10 13:25:34 -04:00
commit 837bb00f2b
20 changed files with 631 additions and 51 deletions

10
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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::<gridcraft_ui_egui::UiState>(&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)

View File

@ -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<DateTime> {
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;

View File

@ -487,7 +487,7 @@ fn integer_args(a: &[Arg]) -> R<Vec<u64>> {
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()
}

View File

@ -1185,7 +1185,7 @@ fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
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 {

View File

@ -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.

View File

@ -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<Out> {
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);

View File

@ -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"

View File

@ -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<String> = None;
let mut chart: Option<u32> = None;
let mut dialog: Option<String> = None;
let mut language: Option<String> = 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;
}

View File

@ -155,16 +155,19 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
}
"engine.commands" => {
let mut v: Vec<Json> = 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))
}

View File

@ -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));

449
crates/ui-egui/src/i18n.rs Normal file
View File

@ -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<Self> {
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> {
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<Language, D::Error> {
#[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<Language> {
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));
}
}
}

View File

@ -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<String>,
/// 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);

View File

@ -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(&current).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(&current)).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) {

View File

@ -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),
);
}
}

View File

@ -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<Color32>, 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,

View File

@ -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());

View File

@ -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);

View File

@ -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).