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.
This commit is contained in:
justinjohn0306 2026-10-09 13:09:48 +05:30
parent 7e24aa398a
commit e8e010b446
10 changed files with 517 additions and 32 deletions

10
Cargo.lock generated
View File

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

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

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

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

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

View File

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

View File

@ -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(&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})),
@ -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) {

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<&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<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

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