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