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:
parent
7e24aa398a
commit
e8e010b446
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -139,7 +139,8 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
let active = app.ui.ribbon_tab == tab;
|
||||
let contextual = ctx_tabs.contains(&tab);
|
||||
let font = theme::ui_font(13.5);
|
||||
let tw = ui.painter().layout_no_wrap(tab.to_string(), font.clone(), t.text).size().x;
|
||||
let label = app.ui.language.tr(tab);
|
||||
let tw = ui.painter().layout_no_wrap(label.to_string(), font.clone(), t.text).size().x;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(tw + 22.0, 30.0), Sense::click());
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 4.0)), 5.0, t.hover);
|
||||
|
|
@ -151,7 +152,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
|||
} else {
|
||||
t.text_dim
|
||||
};
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { theme::ui_bold(13.5) } else { font }, color);
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, label, if active { theme::ui_bold(13.5) } else { font }, color);
|
||||
if active {
|
||||
let ul = Rect::from_center_size(pos2(r.center().x, r.bottom() - 3.0), vec2(tw.clamp(20.0, 40.0), 3.0));
|
||||
ui.painter().rect_filled(ul, 1.5, t.accent);
|
||||
|
|
@ -248,7 +249,8 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json:
|
|||
ui.separator();
|
||||
continue;
|
||||
}
|
||||
if ui.add(egui::Button::new(*label).frame(false).min_size(vec2(220.0, 22.0))).clicked() {
|
||||
let lang = app.ui.language;
|
||||
if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(220.0, 22.0))).clicked() {
|
||||
if let Some(d) = id.strip_prefix("dialog:") {
|
||||
app.open_dialog(d, p.clone());
|
||||
} else {
|
||||
|
|
@ -445,7 +447,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if wrap.clicked() {
|
||||
act(app, "home.wrapText", json!({}));
|
||||
}
|
||||
ui.label(egui::RichText::new("Wrap Text").font(theme::ui_font(12.5)));
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("Wrap Text")).font(theme::ui_font(12.5)));
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, h, id, tip) in [
|
||||
|
|
@ -467,7 +469,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
|||
if m {
|
||||
act(app, "home.mergeCenter", json!({}));
|
||||
}
|
||||
ui.label(egui::RichText::new("Merge & Center").font(theme::ui_font(12.5)));
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("Merge & Center")).font(theme::ui_font(12.5)));
|
||||
egui::Popup::menu(&ma).show(|ui| {
|
||||
menu_items(
|
||||
app,
|
||||
|
|
@ -736,27 +738,28 @@ fn number_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) {
|
|||
let code = st.num_fmt.as_str().to_string();
|
||||
let kind = gridcraft_engine::display::number_format(&code).kind();
|
||||
let current = format!("{kind:?}");
|
||||
let lang = app.ui.language;
|
||||
let sample = app.session.active().and_then(|d| d.wb.active().map(|sh| sh.value(d.selection.active))).unwrap_or_default();
|
||||
let mut picked: Option<&str> = None;
|
||||
egui::ComboBox::from_id_salt("number_format").width(150.0).selected_text(egui::RichText::new(¤t).font(theme::ui_font(12.5))).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
egui::ComboBox::from_id_salt("number_format")
|
||||
.width(150.0)
|
||||
.selected_text(egui::RichText::new(lang.tr(¤t)).font(theme::ui_font(12.5)))
|
||||
.show_ui(ui, |ui| {
|
||||
for name in
|
||||
["General", "Number", "Currency", "Accounting", "Short Date", "Long Date", "Time", "Percentage", "Fraction", "Scientific", "Text"]
|
||||
{
|
||||
let code = gridcraft_engine::cmd::format::format_code_for(name);
|
||||
let preview = app.session.active().map(|d| gridcraft_engine::display::format(&sample, code, &d.wb).text).unwrap_or_default();
|
||||
let r = ui.add(egui::Button::selectable(current == name, format!("{name:<12} {preview}")).min_size(vec2(240.0, 22.0)));
|
||||
let r = ui.add(egui::Button::selectable(current == name, format!("{:<12} {preview}", lang.tr(name))).min_size(vec2(240.0, 22.0)));
|
||||
if r.clicked() {
|
||||
picked = Some(name);
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("More Number Formats…").clicked() {
|
||||
if ui.button(lang.tr("More Number Formats…")).clicked() {
|
||||
picked = Some("__more");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
match picked {
|
||||
Some("__more") => app.open_dialog("formatCells", json!({"tab": "Number"})),
|
||||
Some(n) => act(app, "home.numberFormat", json!({"format": n})),
|
||||
|
|
@ -1263,25 +1266,28 @@ fn page_layout(app: &mut SheetApp, ui: &mut Ui) {
|
|||
.active()
|
||||
.and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings, s.print.gridlines, s.print.headings)))
|
||||
.unwrap_or((true, true, false, false));
|
||||
// These two checkboxes mean the screen view vs. the printed page, so they do not share the
|
||||
// "View" tab label; pick per language rather than through the shared table.
|
||||
let (screen, print) = if app.ui.language == crate::i18n::Language::Ja { ("画面", "印刷") } else { ("View", "Print") };
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Gridlines").strong().small());
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("Gridlines")).strong().small());
|
||||
let mut v = sh.0;
|
||||
if ui.checkbox(&mut v, "View").changed() {
|
||||
if ui.checkbox(&mut v, screen).changed() {
|
||||
act(app, "view.gridlines", json!({"on": v}));
|
||||
}
|
||||
let mut p = sh.2;
|
||||
if ui.checkbox(&mut p, "Print").changed() {
|
||||
if ui.checkbox(&mut p, print).changed() {
|
||||
act(app, "pageLayout.printGridlines", json!({"on": p}));
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new("Headings").strong().small());
|
||||
ui.label(egui::RichText::new(app.ui.language.tr("Headings")).strong().small());
|
||||
let mut v = sh.1;
|
||||
if ui.checkbox(&mut v, "View").changed() {
|
||||
if ui.checkbox(&mut v, screen).changed() {
|
||||
act(app, "view.headings", json!({"on": v}));
|
||||
}
|
||||
let mut p = sh.3;
|
||||
if ui.checkbox(&mut p, "Print").changed() {
|
||||
if ui.checkbox(&mut p, print).changed() {
|
||||
act(app, "pageLayout.printHeadings", json!({"on": p}));
|
||||
}
|
||||
});
|
||||
|
|
@ -1526,17 +1532,18 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
}
|
||||
sep(ui);
|
||||
let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true));
|
||||
let lang = app.ui.language;
|
||||
ui.vertical(|ui| {
|
||||
let mut fb = app.ui.formula_bar;
|
||||
if ui.checkbox(&mut fb, "Formula Bar").changed() {
|
||||
if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() {
|
||||
app.ui.formula_bar = fb;
|
||||
}
|
||||
let mut g = s.0;
|
||||
if ui.checkbox(&mut g, "Gridlines").changed() {
|
||||
if ui.checkbox(&mut g, lang.tr("Gridlines")).changed() {
|
||||
act(app, "view.gridlines", json!({"on": g}));
|
||||
}
|
||||
let mut h = s.1;
|
||||
if ui.checkbox(&mut h, "Headings").changed() {
|
||||
if ui.checkbox(&mut h, lang.tr("Headings")).changed() {
|
||||
act(app, "view.headings", json!({"on": h}));
|
||||
}
|
||||
});
|
||||
|
|
@ -1567,10 +1574,27 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
|
|||
});
|
||||
let mut dark = app.ui.dark;
|
||||
ui.vertical(|ui| {
|
||||
if ui.checkbox(&mut dark, "Dark Mode").changed() {
|
||||
if ui.checkbox(&mut dark, lang.tr("Dark Mode")).changed() {
|
||||
app.ui.dark = dark;
|
||||
}
|
||||
});
|
||||
sep(ui);
|
||||
// Interface language: settings live in the View tab, like Excel's Options. The label follows
|
||||
// the current language so it stays readable; the choices are always shown in their own script.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim));
|
||||
egui::ComboBox::from_id_salt("ui_language").width(120.0).selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for l in crate::i18n::Language::ALL {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
let code = if l == crate::i18n::Language::Ja { "app.language.japanese" } else { "app.language.english" };
|
||||
act(app, code, json!({}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn automate(app: &mut SheetApp, ui: &mut Ui) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue