Browse / github.com/storytold/deckcraft / patch
i18n(ja): Japanese menus, ribbon and status bar (first step)
i18n(ja): Japanese menus, ribbon and status bar (first step) ## What this adds **An interface-language system, ported from the sibling Crafting Apps** (WordCraft, PdfCraft: English strings are the keys, one TSV catalog per language). The UI is still written in English; what is drawn goes through `tl!` / `i18n::t`, so a string without a translation simply shows in English and coverage can grow one row at a time. Command ids, the presentation, file names, the CLI, MCP and the control channel never see translated text. - `UiState.language` is saved with the other UI state (`ui.json`): `auto` (the default) follows the operating system — Windows display
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- midasdf (imported from pull request #1119, open)
- Base
ee875abffa1a31cb760c843bb29e79391a90a766- Size
- +1626 −299 in 44 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e21ab58603aa27a9b7b11310
How it went
No other operator has reported using it yet.
Diff
raw · Cargo.lock Cargo.toml ROADMAP.md crates/ui-egui/Cargo.toml crates/ui-egui/src/app.rs crates/ui-egui/src/app/commands.rs crates/ui-egui/src/app/ime.rs crates/ui-egui/src/backstage.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/dialogs/body.rs crates/ui-egui/src/dialogs/preferences.rs crates/ui-egui/src/i18n/catalog.rs crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/mod.rs crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/language_tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/src/ribbon/animations.rs crates/ui-egui/src/ribbon/arrange.rs crates/ui-egui/src/ribbon/design.rs crates/ui-egui/src/ribbon/draw.rs crates/ui-egui/src/ribbon/galleries.rs crates/ui-egui/src/ribbon/home.rs crates/ui-egui/src/ribbon/insert.rs crates/ui-egui/src/ribbon/masters.rs crates/ui-egui/src/ribbon/picture_format.rs crates/ui-egui/src/ribbon/playback.rs crates/ui-egui/src/ribbon/qat.rs crates/ui-egui/src/ribbon/review.rs crates/ui-egui/src/ribbon/shape_format.rs crates/ui-egui/src/ribbon/slide_show.rs crates/ui-egui/src/ribbon/smartart.rs crates/ui-egui/src/ribbon/table.rs crates/ui-egui/src/ribbon/title_bar.rs crates/ui-egui/src/ribbon/transitions.rs crates/ui-egui/src/ribbon/view.rs crates/ui-egui/src/state.rs crates/ui-egui/src/status.rs crates/ui-egui/src/widgets.rs docs/gaps.md docs/localization-parity.md docs/roadmap.md
@@ -1217,10 +1217,12 @@ dependencies = [ "deckcraft-text", "egui", "egui_kittest",+ "js-sys", "log", "serde", "serde_json", "skrifa 0.47.0",+ "sys-locale", "toml", "web-time", ]@@ -4524,6 +4526,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]]+name = "sys-locale"+version = "0.3.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"+dependencies = [+ "js-sys",+ "libc",+ "wasm-bindgen",+ "web-sys",+]+ [[package]] name = "tar" version = "0.4.46"@@ -52,6 +52,9 @@ unicode-linebreak = "0.1" # Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested # per-crate for targets that read the local clock (see crates/text). chrono = { version = "0.4", default-features = false, features = ["std"] }+# The interface language setting follows the operating system (#8): display language on Windows,+# preferred languages on macOS, LANG/LC_* elsewhere, the browser on the web.+sys-locale = { version = "0.3", features = ["js"] } # text / rendering / formats skrifa = "0.47"@@ -6,6 +6,7 @@ > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (Japanese interface, first step: TSV catalogs, system-language following and an Interface language setting; the menus, ribbon, status bar, command labels and Preferences are drawn in Japanese, other dialog bodies still English; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -90,8 +91,9 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b ## Languages -English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:-[docs/localization-parity.md](docs/localization-parity.md).+English today; the catalog system landed with Japanese as its first catalog (menus, ribbon,+status bar, command labels and Preferences; other dialog bodies still English), and Ukrainian is+in review as PR #34. Detail: [docs/localization-parity.md](docs/localization-parity.md). | Language | Code | UI strings | Status | Hours to full | |---|---|---:|---|---|@@ -103,13 +105,14 @@ English only today; no string catalog yet (Ukrainian is in review as PR #34). De | French | fr | 0% | none | 3–5 | | Portuguese | pt | 0% | none | 3–5 | | Indonesian | id | 0% | none | 3–5 |-| Japanese | ja | 0% | none | 4–7 |+| Japanese | ja | ~53% (menus, ribbon, status bar, Preferences) | partial (in review) | 4–7 | | German | de | 0% | none | 3–5 | | Korean | ko | 0% | none | 4–7 | | Vietnamese | vi | 0% | none | 3–5 | -Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac-ships 26.+The catalog infrastructure is in place (the sibling apps' TSV format, system-language following,+the Interface language setting and catalog tests); a new language is its catalog file plus one row+in `LANGUAGES`. Other languages shipped: none. PowerPoint for Mac ships 26. ## Upcoming @@ -158,6 +161,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide | | 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again | | 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |+| 2026-10-21 | Interface language: a per-language TSV catalog (the sibling apps' system), system-language following, and Preferences ▸ Interface language (auto \| English \| 日本語). The Japanese catalog covers the menus, ribbon tabs and buttons, status bar, command labels, command palette and Preferences; other dialog bodies still English | | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions | | 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour | | 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |@@ -302,6 +306,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |+| 2026-10-21 | minor | Japanese interface, first step: TSV catalogs, system-language following, the Interface language setting and 554 Japanese strings for menus, ribbon, status bar, command labels, command palette and Preferences; other dialog bodies still English | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged | | 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |@@ -22,8 +22,12 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts" serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true }+sys-locale = { workspace = true } web-time = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies]+js-sys = "0.3"+ [target.'cfg(not(target_arch = "wasm32"))'.dependencies] craft-update = { workspace = true } @@ -192,10 +192,17 @@ impl SlideApp { /// opening/inbox results, screenshot completions/deadlines, AutoRecover pacing and media /// clocks. UI events are handled in [`Self::ui`], where a forced hidden paint also arrives. pub fn logic(&mut self, ctx: &egui::Context) {+ // The interface language is drawn from this frame on; `ui()` sets it too, for callers that+ // lay the window out without running `logic` first (`ui_shot`, tests).+ crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.ui.language)); if !Self::root_viewport_admitted(ctx) { log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id()); return; }+ let scale = self.ui.ui_scale.clamp(0.5, 3.0);+ if (ctx.zoom_factor() - scale).abs() > 1e-3 {+ ctx.set_zoom_factor(scale);+ } self.tick_hidden_show(ctx, web_time::Instant::now()); // `raw_input_hook` runs before `App::logic`. In a full pass egui has already built // `PointerState` and `keys_down` from every injected event, so retracting the events after@@ -251,6 +258,7 @@ impl SlideApp { /// no-document paths, reaches the same outer reset observation and finalizer below. No early /// route may publish or inspect IME output on its own. pub fn ui(&mut self, ui: &mut egui::Ui) {+ crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.ui.language)); let ctx = ui.ctx().clone(); if !Self::root_viewport_admitted(&ctx) { log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id());@@ -42,6 +42,16 @@ impl SlideApp { fn run_ui(&mut self, id: &str, p: &Value) -> Result<Value, String> { match id {+ "ui.language" => {+ let value = p.get("value").and_then(Value::as_str).ok_or("`value` must be \"auto\", \"en\" or \"ja\"")?;+ let pref = crate::i18n::normalize_pref(value).ok_or_else(|| {+ format!("`value` must be \"auto\" or one of {:?}", crate::i18n::Lang::all().map(crate::i18n::Lang::code).collect::<Vec<_>>())+ })?;+ self.ui.language = pref.to_string();+ // The next frame draws in it; tests and the control channel see it at once.+ crate::i18n::set_current(crate::i18n::Lang::from_pref(pref));+ return Ok(json!({"language": pref}));+ } "view.normal" => self.ui.view = ViewMode::Normal, "view.outline" => self.ui.view = ViewMode::Outline, "view.sorter" => self.ui.view = ViewMode::Sorter,@@ -24,6 +24,11 @@ impl SlideApp { if let Some(st) = self.session.active() { self.ui_fonts.note_doc(ctx, st); }+ // The interface's own text in a CJK language needs a face for the characters the embedded+ // faces lack (a build without craft-fonts); borrow one from the installed fonts.+ if crate::i18n::current().uses_cjk() {+ self.ui_fonts.note(ctx, crate::i18n::current().catalog_text());+ } self.ui_fonts.apply(ctx); } @@ -328,7 +328,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 0.0; if has_doc { let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click());- resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back"));+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Back"))); let hovered = resp.hovered(); if hovered { ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover);@@ -391,7 +391,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { egui::Frame::NONE.inner_margin(egui::Margin { left: 48, right: 36, top: 22, bottom: 32 }).show(ui, |ui| { ui.set_max_width(CONTENT_W); if bs.page != Page::Home {- ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text));+ ui.label(RichText::new(tl!(bs.page.label())).font(theme::bold(22.0)).color(t.text)); ui.add_space(16.0); } // Errors raised on a backstage page (a missing recent file, a failed open) would@@ -432,7 +432,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click()); let selected = nav == Nav::Page(bs.page);- let label = nav.label();+ let label = tl!(nav.label()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let p = ui.painter(); let row = rect.shrink2(vec2(6.0, 1.0));@@ -460,6 +460,7 @@ fn separator(ui: &mut Ui, t: &Tokens) { fn heading(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx());+ let text = tl!(text); ui.label(RichText::new(text).font(theme::bold(16.0)).color(t.text)); ui.add_space(8.0); }@@ -83,7 +83,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; let mut close = false;- let mut window = egui::Window::new(title(&d.id))+ let mut window = egui::Window::new(tl!(title(&d.id))) .id(egui::Id::unique(("dialog", d.id.clone()))) .collapsible(false) .resizable(false)@@ -129,6 +129,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { let mut ok = false; let mut cancel = false;+ let ok_label = tl!(ok_label); ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let t = Tokens::get(ui.ctx());@@ -137,7 +138,7 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { { ok = true; }- if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {+ if ui.add(egui::Button::new(tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { cancel = true; } });@@ -258,7 +258,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } done = true; }- if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {+ if ui.add(egui::Button::new(tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { done = true; } });@@ -1276,9 +1276,9 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if d.params.get("before").is_none() { d.params = json!({ "before": preferences_snapshot(app) }); }- ui.label(egui::RichText::new("General").font(theme::bold(13.0)));+ ui.label(egui::RichText::new(tl!("General")).font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark;- if ui.checkbox(&mut dark, "Dark appearance").changed() {+ if ui.checkbox(&mut dark, tl!("Dark appearance")).changed() { let _ = app.run("view.dark", json!({"on": dark})); } // While the pointer holds the slider its value only waits in the dialog: applying it@@ -1289,7 +1289,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); // No visible label here (the row is laid out to fit); the accessible name only. let enabled = r.enabled();- r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size"));+ r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), tl!("Interface size"))); if r.is_pointer_button_down_on() { if r.changed() || pending.is_some() { d.fields.insert("uiScale".into(), scale.to_string());@@ -1300,20 +1300,47 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.remove("uiScale"); }+ // Interface language: follow the system (the default) or pick a catalog by hand. It+ // applies at once, so the dialog's own text changes with it; Cancel puts the opening+ // setting back (`restore_preferences`).+ ui.label(egui::RichText::new(tl!("Interface language:")).font(theme::bold(13.0)));+ let auto = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", crate::i18n::system_lang().english_name())]);+ let current =+ if app.ui.language == crate::i18n::AUTO { auto.clone() } else { crate::i18n::Lang::from_pref(&app.ui.language).name().to_string() };+ let current_label = current.clone();+ let opened = egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| {+ if ui.selectable_label(app.ui.language == crate::i18n::AUTO, auto.as_str()).clicked() {+ let _ = app.run("ui.language", json!({"value": crate::i18n::AUTO}));+ }+ for lang in crate::i18n::Lang::all().filter(|l| *l != crate::i18n::Lang::EN) {+ if ui.selectable_label(app.ui.language == lang.code(), lang.name()).clicked() {+ let _ = app.run("ui.language", json!({"value": lang.code()}));+ }+ }+ });+ // A ComboBox has no accessible label of its own: name it with the chosen option so+ // screen readers and UI tests can find the picker.+ let opened = opened.response;+ opened.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, ¤t_label));+ ui.label(+ egui::RichText::new(tl!("Menus, commands and the status bar change. Your presentation and file names don't."))+ .size(11.0)+ .color(Tokens::get(ui.ctx()).text_dim),+ ); // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. ui.horizontal(|ui| {- ui.label("Measurement units:");+ ui.label(tl!("Measurement units:")); let units = &mut app.session.prefs.units;- ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches");- ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters");+ ui.radio_value(units, deckcraft_geom::Unit::Inches, tl!("Inches"));+ ui.radio_value(units, deckcraft_geom::Unit::Centimeters, tl!("Centimeters")); });- ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));- ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");- ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid");- ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes");+ ui.label(egui::RichText::new(tl!("Editing")).font(theme::bold(13.0)));+ ui.checkbox(&mut app.session.prefs.smart_guides, tl!("Show smart guides"));+ ui.checkbox(&mut app.session.prefs.snap_to_grid, tl!("Snap objects to grid"));+ ui.checkbox(&mut app.session.prefs.smart_quotes, tl!("Replace straight quotes with smart quotes")); ui.horizontal(|ui| {- ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type");- if ui.button("AutoCorrect Options…").clicked() {+ ui.checkbox(&mut app.session.prefs.autocorrect, tl!("AutoCorrect as you type"));+ if ui.button(tl!("AutoCorrect Options…")).clicked() { *d = Dialog::new("autocorrect"); } });@@ -1321,7 +1348,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { return false; } ui.horizontal(|ui| {- let label = ui.label("Your name (comments):");+ let label = ui.label(tl!("Your name (comments):")); // Name the field for assistive technology; a bare text edit next to a label is an // unnamed input in egui 0.37's accessibility tree. let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id);@@ -1337,7 +1364,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "themeFonts" => theme_fonts_dialog(app, ui, d), "dateTime" => date_time_dialog(app, ui, d), _ => {- ui.label(format!("“{}” is not available yet.", d.id));+ ui.label(crate::i18n::fmt(tl!("“{dialog}” is not available yet."), &[("dialog", &d.id)])); let (ok, cancel) = buttons(ui, "OK"); ok || cancel }@@ -5,6 +5,7 @@ pub(super) fn preferences_snapshot(app: &SlideApp) -> Value { json!({ "dark": app.ui.brightness == theme::Brightness::Dark, "uiScale": app.ui.ui_scale,+ "language": app.ui.language, "prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null), }) }@@ -20,6 +21,11 @@ pub(super) fn restore_preferences(app: &mut SlideApp, d: &Dialog) { if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) { app.ui.ui_scale = scale as f32; }+ if let Some(language) = before.get("language").and_then(Value::as_str)+ && language != app.ui.language+ {+ let _ = app.run("ui.language", json!({ "value": language }));+ } if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) { app.session.prefs = prefs; }new file mode 100644@@ -0,0 +1,125 @@+//! Parser and lookup table for interface catalogs (`*.tsv`). The format is the Crafting Apps'+//! shared one (PdfCraft, PhotoCraft, WordCraft): one entry per line,+//! `English source<TAB>translation`, `#` comment lines and blank lines ignored. Escapes are+//! `\n`, `\t` and `\\`, so a label that breaks a ribbon button over two lines keeps its newline.+//!+//! Validation is strict — a missing/extra column, an empty translation, a duplicate key, a+//! `{placeholder}` that differs from the English, or a trailing ellipsis on only one side — but a+//! bad line is only skipped and reported: the rest of the catalog still loads and the skipped+//! string shows in English. The bundled catalogs must parse without a single error (`tests.rs`).+//! Reproducing a product's translated UI wording is not allowed (clean-room, AGENTS.md §2): the+//! translations are our own.++use std::collections::{HashMap, HashSet};++/// A catalog entry as read from the file.+#[derive(Debug, PartialEq, Eq)]+pub struct Entry {+ pub source: String,+ pub translation: String,+}++/// A parsed catalog. Values are owned for the life of the process (catalogs are built once).+#[derive(Debug, Default)]+pub struct Catalog {+ /// English source → translation (the hot path, looked up every frame).+ plain: HashMap<String, String>,+}++fn unescape(text: &str) -> Result<String, String> {+ let mut out = String::with_capacity(text.len());+ let mut chars = text.chars();+ while let Some(c) = chars.next() {+ if c != '\\' {+ out.push(c);+ continue;+ }+ match chars.next() {+ Some('n') => out.push('\n'),+ Some('t') => out.push('\t'),+ Some('\\') => out.push('\\'),+ Some(other) => return Err(format!("unknown escape \\{other}; use \\n, \\t or \\\\")),+ None => return Err("trailing backslash; use \\\\ for a literal backslash".into()),+ }+ }+ Ok(out)+}++/// `{name}` placeholders of a template, sorted (translators may reorder them).+pub fn placeholders(text: &str) -> Vec<&str> {+ let mut names = Vec::new();+ let mut rest = text;+ while let Some((_, after_open)) = rest.split_once('{') {+ let Some((name, after_close)) = after_open.split_once('}') else { break };+ names.push(name);+ rest = after_close;+ }+ names.sort_unstable();+ names+}++/// Check one entry against its English source.+fn validate(entry: &Entry) -> Result<(), String> {+ let Entry { source, translation } = entry;+ if placeholders(source) != placeholders(translation) {+ return Err(format!("placeholders differ from the English source: {:?} vs {:?}", placeholders(source), placeholders(translation)));+ }+ if source.ends_with('…') != translation.ends_with('…') {+ return Err("trailing ellipsis differs from the English source".into());+ }+ Ok(())+}++fn parse_line(line: &str) -> Result<Entry, String> {+ let mut columns = line.split('\t');+ let (Some(source), Some(translation), None) = (columns.next(), columns.next(), columns.next()) else {+ return Err("expected `source<TAB>translation`".into());+ };+ let entry = Entry { source: unescape(source)?, translation: unescape(translation)? };+ if entry.source.is_empty() || entry.translation.is_empty() {+ return Err("source and translation must be nonempty".into());+ }+ validate(&entry)?;+ Ok(entry)+}++/// Read the entries of a catalog file. Malformed lines and duplicates (the first entry wins) are+/// returned as errors and skipped, so a bad translation never breaks the UI.+pub fn parse_entries(text: &str) -> (Vec<Entry>, Vec<String>) {+ let mut entries = Vec::new();+ let mut errors = Vec::new();+ let mut seen = HashSet::new();+ for (index, line) in text.lines().enumerate() {+ if line.trim().is_empty() || line.starts_with('#') {+ continue;+ }+ let line_no = index.saturating_add(1);+ match parse_line(line) {+ Ok(entry) if !seen.insert(entry.source.clone()) => errors.push(format!("line {line_no}: duplicate key {:?}", entry.source)),+ Ok(entry) => entries.push(entry),+ Err(error) => errors.push(format!("line {line_no}: {error}")),+ }+ }+ (entries, errors)+}++impl Catalog {+ /// Build a catalog from its valid entries; the errors of the skipped lines come back too.+ pub fn parse(text: &str) -> (Catalog, Vec<String>) {+ let (entries, errors) = parse_entries(text);+ let mut catalog = Catalog::default();+ for e in entries {+ catalog.plain.insert(e.source, e.translation);+ }+ (catalog, errors)+ }++ pub fn plain(&self, s: &str) -> Option<&str> {+ self.plain.get(s).map(String::as_str)+ }++ /// How many strings the catalog translates (the coverage number in the docs).+ pub fn len(&self) -> usize {+ self.plain.len()+ }+}new file mode 100644@@ -0,0 +1,558 @@+# Japanese (ja) interface catalog. Same format and clean-room rules as the sibling Crafting+# Apps' catalogs: `English source<TAB>translation`, `#` comments, escapes `\n`, `\t`, `\\`.+# The English text is the key the UI looks up; a string without a row shows in English.+# Translations are our own wording (clean-room: never copied from another product's UI).+(not checked) (未チェック)+About DeckCraft DeckCraft について+Accept Revision 変更を承諾+Accept\nAll すべて\n承諾+Accessibility Checker アクセシビリティチェッカー+Account アカウント+Action 動作+Action Buttons 動作ボタン+Action Settings 動作設定+Add Animation アニメーションの追加+Add Guide ガイドの追加+Add Point 頂点の追加+Add Section セクションの追加+Add Shape 図形の追加+Add to Quick Access Toolbar クイックアクセスツールバーに追加+Add\nAnimation アニメーション\nの追加+Adjust Shape 図形の調整+Align 配置+Align Cell Text セル内の文字配置+Align Left 左揃え+Align Right 右揃え+Align Text 文字の配置+Alt Text 代替テキスト+Alt\nText 代替\nテキスト+Animation Styles アニメーションスタイル+Animation\nPainter アニメーション\nの複製+Animation\nPane アニメーション\nウィンドウ+Animations アニメーション+Apply 適用+Apply Animation アニメーションを適用+Apply Design Idea デザインアイデアを適用+Apply\nTo All すべてに\n適用+Arrange 整列+Artistic Effects 芸術効果+Artistic\nEffects 芸術\n効果+Audio 音声+Audio from File… ファイルから音声…+AutoCorrect オートコレクト+AutoCorrect Options… オートコレクトのオプション…+AutoCorrect as you type 入力時にオートコレクト+Autofit 自動調整+Automatic 自動+Automatic ({language}) 自動 ({language})+Back 戻る+Background\nStyles 背景\nスタイル+Bold 太字+Borders 罫線+Bring Forward 前面へ移動+Bring to Front 最前面へ移動+Bring\nForward 前面へ\n移動+Browse for Themes… テーマを参照…+Bullets 箇条書き+Bullets and Numbering 箇条書きと段落番号+Cameo カメオ+Cancel キャンセル+Caps 大文字+Cell Margins セルの余白+Center 中央揃え+Change Case 大文字と小文字の変更+Change Chart Type グラフの種類の変更+Change Colors 色の変更+Change Layout レイアウトの変更+Change Picture 図の変更+Change Picture… 図の変更…+Change Shape 図形の変更+Change Spelling スペルの修正+Change\nChart Type グラフの\n種類の変更+Character Spacing 文字間隔+Chart グラフ+Chart Data グラフデータ+Chart Design グラフデザイン+Chart Elements グラフ要素+Chart… グラフ…+Check\nAccessibility アクセシビリティ\nチェック+Choose a SmartArt Graphic SmartArt グラフィックの選択+Clear All Formatting すべての書式をクリア+Click to add notes クリックしてノートを入力+Close 閉じる+Close\nMaster View マスター\nビューを閉じる+Collapse the Ribbon リボンを折りたたむ+Color 色+Colors 色+Columns 列+Command Palette コマンドパレット+Comment コメント+Community Links コミュニティリンク+Compare 比較+Compare… 比較…+Compress Pictures 図の圧縮+Connect Shapes 図形の接続+Convert 変換+Convert to Shapes 図形に変換+Convert to SmartArt SmartArt に変換+Convert to Text テキストに変換+Copy コピー+Corrections 修正+Create New Theme Colors 新しいテーマの色を作成+Create New Theme Fonts 新しいテーマのフォントを作成+Crop トリミング+Custom Shows ユーザー設定のスライドショー+Custom Slide Show ユーザー設定のスライドショー+Custom\nShow ユーザー設定の\nスライドショー+Cut 切り取り+Dark Mode ダークモード+Dark appearance ダーク表示+Dark\nMode ダーク\nモード+Date & Time 日付と時刻+Date &\nTime 日付と\n時刻+Date and Time 日付と時刻+Decrease Font Size フォントサイズの縮小+Decrease List Level リストレベルの上げ+Delete 削除+Delete Columns 列の削除+Delete Comment コメントの削除+Delete Guide ガイドの削除+Delete Layout レイアウトの削除+Delete Point 頂点の削除+Delete Rows 行の削除+Delete Slide スライドの削除+Delete Table 表の削除+Delete Text テキストの削除+Demote レベル下げ+Design デザイン+Design\nIdeas デザイン\nアイデア+Distribute 分布+Distribute Columns 列の分布+Distribute Horizontally 左右に分布+Distribute Rows 行の分布+Distribute Vertically 上下に分布+Done 完了+Draw 描画+Draw Table 表の作成+Duplicate 複製+Duplicate Slide スライドの複製+Edit 編集+Edit Data データの編集+Edit Photo Album… フォトアルバムの編集…+Edit Points 頂点の編集+Edit SmartArt Text SmartArt テキストの編集+Edit\nData データの\n編集+Edit\nShape 図形の\n編集+Editing 編集+Effect Options 効果のオプション+Effect\nOptions 効果の\nオプション+Effects 効果+End Show スライドショーの終了+End\nReview 校閲の\n終了+English 英語+Equation 数式+Eraser 消しゴム+Export エクスポート+Export… エクスポート…+File ファイル+File (Alt+F) ファイル (Alt+F)+Find 検索+Find and Replace 検索と置換+Find… 検索…+Fit slide to current window スライドをウィンドウに合わせる+Fit to Window ウィンドウに合わせる+Fit to\nWindow ウィンドウに\n合わせる+Flip 反転+Flip Horizontal 左右反転+Flip Vertical 上下反転+Font フォント+Font Color フォントの色+Font Size フォントサイズ+Fonts フォント+Font… フォント…+Format 書式+Format Background 背景の書式設定+Format\nBackground 背景の\n書式設定+Format\nPane 書式設定\nウィンドウ+Freeform フリーフォーム+From\nBeginning 最初から\n再生+From\nCurrent Slide 現在のスライド\nから再生+General 一般+Grayscale グレースケール+Grid Options グリッドオプション+Grid Options… グリッドオプション…+Grid\nOptions グリッド\nオプション+Gridlines グリッド線+Group グループ化+Guides ガイド+Handout Master 配布資料マスター+Handout Placeholders 配布資料のプレースホルダー+Handout\nMaster 配布資料\nマスター+Header &\nFooter ヘッダーと\nフッター+Header and Footer ヘッダーとフッター+Header and Footer… ヘッダーとフッター…+Height 高さ+Help ヘルプ+Hide All すべて非表示+Hide Background Graphics 背景のグラフィックを非表示+Hide Slide スライドを非表示+Hide\nSlide スライドを\n非表示+Home ホーム+Icons アイコン+Increase Font Size フォントサイズの拡大+Increase List Level リストレベルの下げ+Info 情報+Ink to Shape インクを図形に変換+Ink to\nMath インクを\n数式に変換+Ink to\nShape インクを\n図形に変換+Ink to\nText インクを\nテキストに変換+Insert 挿入+Insert Above 上に挿入+Insert Below 下に挿入+Insert Hyperlink ハイパーリンクの挿入+Insert Layout レイアウトの挿入+Insert Left 左に挿入+Insert Picture 図の挿入+Insert Picture in SmartArt SmartArt に図を挿入+Insert Picture in SmartArt… SmartArt に図を挿入…+Insert Placeholder プレースホルダーの挿入+Insert Right 右に挿入+Insert Synonym 類語の挿入+Insert Table 表の挿入+Insert\nAbove 上に\n挿入+Insert\nBelow 下に\n挿入+Insert\nLayout レイアウトの\n挿入+Insert\nLeft 左に\n挿入+Insert\nPlaceholder プレース\nホルダーの挿入+Insert\nRight 右に\n挿入+Interface language: 表示言語:+Interface size 表示サイズ+Italic 斜体+Japanese 日本語+Justify 両端揃え+Language 言語+Layout レイアウト+Layout Placeholders レイアウトのプレースホルダー+Layouts レイアウト+Learn\nMore 詳細\n情報+Line Spacing 行間+Link リンク+Lock ロック+Menus, commands and the status bar change. Your presentation and file names don't. メニュー・コマンド・ステータスバーが変わります。プレゼンテーションとファイル名は変わりません。+Merge Cells セルの結合+Merge Shapes 図形の結合+Merge\nCells セルの\n結合+Merge\nShapes 図形の\n結合+Move 移動+Move Guide ガイドの移動+Move Handle 移動ハンドル+Move Point 頂点の移動+Move Section セクションの移動+Move Slide スライドの移動+New 新規作成+New Comment 新しいコメント+New Photo Album… 新しいフォトアルバム…+New Presentation 新しいプレゼンテーション+New Slide 新しいスライド+New Window 新しいウィンドウ+New\nComment 新しい\nコメント+New\nSlide 新しい\nスライド+New\nWindow 新しい\nウィンドウ+Normal 標準+Notes ノート+Notes Master ノートマスター+Notes Page ノートページ+Notes Placeholders ノートのプレースホルダー+Notes\nMaster ノート\nマスター+Nudge ナッジ+Numbering 段落番号+OK OK+Open 開く+Open Dialog ダイアログを開く+Open… 開く…+Options オプション+Outline View アウトラインビュー+Paragraph 段落+Paragraph… 段落…+Paste 貼り付け+Paste Formatting 書式を貼り付け+Paste Special 形式を選択して貼り付け+Paste Special… 形式を選択して貼り付け…+Paste and Match Formatting 書式を合わせて貼り付け+Photo Album フォトアルバム+Photo Album… フォトアルバム…+Photo\nAlbum フォト\nアルバム+Picture 図+Picture Border 図の枠線+Picture Format 図の書式設定+Picture Styles 図のスタイル+Picture from File… ファイルから図…+Pictures 図+Play from Current Slide 現在のスライドから再生+Play from Start 最初から再生+Play from\nCurrent Slide 現在のスライド\nから再生+Play from\nStart 最初から\n再生+Playback 再生+Point Type 頂点の種類+Position and Size 位置とサイズ+Poster Frame ポスターフレーム+Preferences 環境設定+Preferences… 環境設定…+Presenter View 発表者ビュー+Presenter\nView 発表者\nビュー+Preserve 保持+Preview プレビュー+Preview Animations アニメーションのプレビュー+Print 印刷+Print… 印刷…+Promote レベル上げ+Properties プロパティ+Quick Access Toolbar クイックアクセスツールバー+Quick Styles クイックスタイル+Quick\nStyles クイック\nスタイル+Reading View 閲覧ビュー+Recolor 色の変更+Record 記録+Redo やり直し+Regroup 再グループ化+Rehearse\nTimings タイミングの\nリハーサル+Remove All Animations すべてのアニメーションを削除+Remove All Sections すべてのセクションを削除+Remove Animation アニメーションの削除+Remove Background 背景の削除+Remove Section セクションの削除+Remove Shape 図形の削除+Remove from Quick Access Toolbar クイックアクセスツールバーから削除+Remove\nBackground 背景の\n削除+Rename 名前の変更+Rename Layout レイアウト名の変更+Rename Section セクション名の変更+Rename Slide スライド名の変更+Reorder Animation アニメーションの並べ替え+Reorder Objects オブジェクトの並べ替え+Replace 置換+Replace Font フォントの置換+Replace Fonts フォントの置換+Replace Fonts… フォントの置換…+Replace straight quotes with smart quotes 直線引用符をスマート引用符に置換+Replace… 置換…+Reply 返信+Reset リセット+Reset Picture 図のリセット+Resolve Thread スレッドを解決+Reuse Slides… スライドの再利用…+Review 校閲+Ribbon Tab リボンタブ+Right-to-Left Text Direction テキストを右から左に+Rotate 回転+Rotate Left 90° 左に 90° 回転+Rotate Right 90° 右に 90° 回転+Rotation 回転角度+Ruler ルーラー+Save 保存+Save As 名前を付けて保存+Save As… 名前を付けて保存…+Screenshot スクリーンショット+Search commands… コマンドを検索…+Section セクション+Select 選択+Select All すべて選択+Selection\nPane 選択\nウィンドウ+Send Backward 背面へ移動+Send to Back 最背面へ移動+Send\nBackward 背面へ\n移動+Set Text テキストの設定+Set Transparent Color 透明色の設定+Set Up Show… スライドショーの設定…+Set Up Slide Show スライドショーの設定+Set Up Slide Show… スライドショーの設定…+Set Up\nSlide Show スライドショーの\n設定+Set Zoom ズームの設定+Set as Default Shape 既定の図形に設定+Shading 網掛け+Shape Effects 図形の効果+Shape Fill 図形の塗りつぶし+Shape Format 図形の書式+Shape Outline 図形の輪郭+Shape\nStyles 図形\nスタイル+Shapes 図形+Show All すべて表示+Show smart guides スマートガイドを表示+Show/Hide 表示/非表示+Show\nComments コメントを\n表示+Size サイズ+Size and Position サイズと位置+Size and Position… サイズと位置…+Slide Master スライドマスター+Slide Number スライド番号+Slide Show スライドショー+Slide Size スライドサイズ+Slide Sorter スライド一覧+Slide {current} of {total} スライド {current} / {total}+Slide\nMaster スライド\nマスター+Slide\nNumber スライド\n番号+Slide\nSize スライド\nサイズ+Slides from Outline アウトラインからのスライド+Slides from Outline… アウトラインからのスライド…+SmartArt SmartArt+SmartArt Design SmartArt デザイン+SmartArt Styles SmartArt スタイル+Smooth Corners 角を丸める+Snap objects to grid 図形をグリッドに合わせる+Sound サウンド+Spelling スペルチェック+Split Cells セルの分割+Split\nCells セルの\n分割+Strikethrough 取り消し線+Subscript 下付き+Superscript 上付き+Symbol 記号+Table 表+Table Design 表デザイン+Table Eraser 表の消しゴム+Table Style Options 表スタイルのオプション+Table Styles 表のスタイル+Table… 表…+Tabs タブ+Tabs… タブ…+Task Pane 作業ウィンドウ+Text Box テキストボックス+Text Box Margins テキストボックスの余白+Text Direction テキストの方向+Text Effects テキスト効果+Text Fill テキストの塗りつぶし+Text Highlight Color テキストの強調表示色+Text Outline テキストの輪郭+Text Shadow テキストの影+Text Transform テキストの変形+Text Transparency テキストの透過+Text\nBox テキスト\nボックス+Text\nPane テキスト\nウィンドウ+Themes テーマ+Thesaurus 類語辞典+Timing タイミング+Transition 画面切り替え+Transitions 画面切り替え+Translate 翻訳+Transparency 透過+Trigger トリガー+Trim トリミング+Trim Media メディアのトリム+Type Text テキストを入力+Underline 下線+Undo 元に戻す+Ungroup グループ解除+Variants バリエーション+Video ビデオ+Video from File… ファイルからビデオ…+View 表示+Volume 音量+Width 幅+WordArt ワードアート+Wrap Text in Shape 図形内でテキストを折り返す+Your name (comments): 名前 (コメント用):+Zoom ズーム+Zoom In 拡大+Zoom Out 縮小+Zoom… ズーム…+“{dialog}” is not available yet. 「{dialog}」はまだ利用できません。+ (not checked) (未チェック)+Appearance 外観+Customize Quick Access Toolbar クイックアクセスツールバーのカスタマイズ+Interface Language 表示言語+Photo Album options フォトアルバムのオプション+Presentations プレゼンテーション+Product Information 製品情報+Reset to Defaults 既定値に戻す+Right-click any ribbon button to add it. リボンの任意のボタンを右クリックして追加できます。+Settings 設定+Start from Beginning 最初から再生+Templates テンプレート+User Information ユーザー情報+ Present プレゼン +3-D 3-D+Action Settings… 動作設定…+After: 後:+Animation Painter (double-click to keep it on) アニメーションの複製 (ダブルクリックで固定)+Apply only to this picture この図にのみ適用+Arrows 矢印+Banded Columns 縞模様 (列)+Banded Rows 縞模様 (行)+Bevel 面取り+Bullets and Numbering… 箇条書きと段落番号…+Camera capture is not available yet カメラ入力はまだ利用できません+Comments コメント+Compress 圧縮+Crop to Shape 図形に合わせてトリミング+Customize Colors… 色のカスタマイズ…+Customize Fonts… フォントのカスタマイズ…+Dashes 破線+Delay: 遅延:+Delete cropped areas of pictures トリミングした部分を削除+Duplicate Selected Slides 選択したスライドの複製+Duration: 継続時間:+First Column 最初の列+Fit 合わせる+Follow Path パスに沿う+Footers フッター+Format Painter (double-click to keep it on) 書式のコピー (ダブルクリックで固定)+Format Shape… 図形の書式設定…+Glow 光彩+Gradient… グラデーション…+Header Row 見出し行+Height: 高さ:+Hide During Show スライドショー中は非表示+Ink to Math (coming soon) インクを数式に変換 (近日対応)+Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes インクを図形に変換: 描いた線・四角形・楕円・三角形を図形にします+Ink to Text (coming soon) インクをテキストに変換 (近日対応)+Insert Table… 表の挿入…+Last Column 最後の列+Line Spacing Options… 行間のオプション…+Loop until Stopped 停止するまで繰り返す+New Slide (⇧⌘N) 新しいスライド (⇧⌘N)+No Glow 光彩なし+No icons match. 一致するアイコンがありません。+None なし+On Click of… (none) クリック時… (なし)+On Mouse Click マウスクリック時+Open another window on this presentation このプレゼンテーションを新しいウィンドウで開く+Other Sound… その他のサウンド…+Page Setup… ページ設定…+Paste (⌘V) 貼り付け (⌘V)+Pen Color ペンの色+Pen Style ペンのスタイル+Pen Weight ペンの太さ+Pixels of this colour become transparent: この色のピクセルを透明にします:+Play Across Slides 複数のスライドで再生+Play Full Screen 全画面で再生+Play Narrations ナレーションを再生+Play this slide's animations on the canvas このスライドのアニメーションをキャンバスで再生+Put a picture in the selected node's picture placeholder 選択したノードの図のプレースホルダーに図を配置します+Recording narration needs audio capture (coming soon) ナレーションの録音には音声入力が必要です (近日対応)+Reflection 反射+Remove the picture style 図のスタイルを解除+Rescan Installed Fonts インストール済みフォントを再読み込み+Reset Slide Background スライドの背景をリセット+Rewind after Playing 再生後に巻き戻す+Screenshot (not available yet) スクリーンショット (未対応)+Selection Pane… 選択ウィンドウ…+Shadow 影+Show Media Controls メディアコントロールを表示+Soft Edges ぼかし+Sound: サウンド:+Standard (4:3) 標準 (4:3)+Start Automatically 自動的に開始+Start: 開始:+Title タイトル+Total Row 集計行+Translation needs a language model (not bundled) 翻訳には言語モデルが必要です (同梱されていません)+Type and organise the diagram's text 図のテキストを入力・整理します+Use Timings タイミングを使用+Warp ワープ+Weight 太さ+Widescreen (16:9) ワイド画面 (16:9)+Width: 幅:+Width: 幅: +[No Sound] [サウンドなし]+[Stop Previous Sound] [前のサウンドを停止]+Reset Style スタイルをリセットnew file mode 100644@@ -0,0 +1,252 @@+//! Interface languages: the UI is written in English, and a catalog per language (`*.tsv`)+//! translates what is drawn when the interface is shown in another language. English strings stay+//! the keys, so command ids, the document, file names, the control channel, the CLI and MCP never+//! see translated text: scripts and agents are unaffected, and a string without a translation+//! simply shows in English, so coverage can grow one entry at a time.+//!+//! The `language` setting is `auto` by default: the UI follows the operating system's language+//! (the Windows display language, macOS preferred languages, `LANG`/`LC_*` elsewhere, the+//! browser on the web) when there is a catalog for it, and English otherwise. Preferences ▸+//! Interface language picks one by hand.+//!+//! This is the system the sibling Crafting Apps use (`wordcraft/crates/ui-egui/src/i18n`,+//! `pdfcraft/.../i18n`, `photocraft/.../i18n`); the catalog format and habits carry over between+//! the apps, so strings and translations port between them.+//!+//! # Adding a language+//! 1. Add `xx.tsv` next to `ja.tsv` (copy its header; translate from the *meaning* of the English+//! text — clean-room, never from Microsoft PowerPoint's or another product's localisation).+//! 2. Add one row to [`LANGUAGES`]. The Preferences dropdown, the system-language match and the+//! catalog tests pick it up from there.+//!+//! # Looking strings up+//! - [`tl!`](crate::tl) / [`t`]: a string in the current language; [`tr`]: in a given one.+//! - [`fmt`]: fill `{name}` placeholders after a lookup, so translators may reorder them.++mod catalog;++use std::cell::Cell;+use std::sync::OnceLock;++use catalog::Catalog;++/// The `language` setting that follows the system.+pub const AUTO: &str = "auto";++/// One supported interface language.+pub struct LangInfo {+ /// BCP 47 code, lowercase (`ja`). Also the `language` setting's value.+ pub code: &'static str,+ /// The language's name in itself, shown in the Preferences dropdown.+ pub name: &'static str,+ /// The English name, shown beside "Automatic" so an English speaker can confirm the system+ /// language it followed.+ pub english_name: &'static str,+ /// Catalog file contents (empty for the built-in English).+ pub source: &'static str,+ catalog: OnceLock<Catalog>,+}++/// The registry. English first: it is the fallback and the source language.+pub static LANGUAGES: [LangInfo; 2] = [+ LangInfo { code: "en", name: "English", english_name: "English", source: "", catalog: OnceLock::new() },+ LangInfo { code: "ja", name: "日本語", english_name: "Japanese", source: include_str!("ja.tsv"), catalog: OnceLock::new() },+];++impl LangInfo {+ fn catalog(&self) -> &Catalog {+ self.catalog.get_or_init(|| {+ let (catalog, errors) = Catalog::parse(self.source);+ for error in errors {+ log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);+ }+ catalog+ })+ }+}++/// A language the UI can be shown in (a handle into [`LANGUAGES`]).+#[derive(Clone, Copy)]+pub struct Lang(&'static LangInfo);++impl std::fmt::Debug for Lang {+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+ write!(f, "Lang({})", self.0.code)+ }+}++impl PartialEq for Lang {+ fn eq(&self, other: &Self) -> bool {+ self.0.code == other.0.code+ }+}++impl Eq for Lang {}++impl Lang {+ pub const EN: Lang = Lang(&LANGUAGES[0]);++ pub fn code(self) -> &'static str {+ self.0.code+ }++ pub fn name(self) -> &'static str {+ self.0.name+ }++ /// The language's English name (for the "Automatic (Japanese)" line).+ pub fn english_name(self) -> &'static str {+ self.0.english_name+ }++ /// A language by its exact code (any case).+ pub fn from_code(code: &str) -> Option<Lang> {+ LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)+ }++ /// Resolve the `language` setting: a language code, or [`AUTO`] (and anything unknown, e.g. a+ /// code from a newer version) to follow the system.+ pub fn from_pref(pref: &str) -> Lang {+ Lang::from_code(pref).unwrap_or_else(system_lang)+ }++ /// Every registered language.+ pub fn all() -> impl Iterator<Item = Lang> {+ LANGUAGES.iter().map(Lang)+ }++ fn catalog(self) -> &'static Catalog {+ self.0.catalog()+ }++ /// Does the interface written in this language use CJK script? (It needs a CJK face, which a+ /// build without craft-fonts borrows from the installed fonts, see `uifonts`.)+ pub fn uses_cjk(self) -> bool {+ self.0.source.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))+ }++ /// The catalog file's text: the characters the interface draws in this language.+ pub fn catalog_text(self) -> &'static str {+ self.0.source+ }++ /// How many strings this language translates (the coverage number in the docs).+ pub fn translated_strings(self) -> usize {+ self.catalog().len()+ }+}++/// The canonical `language` setting for user input: [`AUTO`] or a registered code (any case).+/// `None` for anything else, so callers keep the current setting.+pub fn normalize_pref(pref: &str) -> Option<&'static str> {+ if pref.eq_ignore_ascii_case(AUTO) {+ return Some(AUTO);+ }+ Lang::from_code(pref).map(Lang::code)+}++/// Candidate codes for a locale tag, most specific first: `ja_JP.UTF-8` → `ja-jp`, `ja`.+fn candidates(tag: &str) -> Vec<String> {+ let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();+ let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();+ let mut out = Vec::new();+ for n in (1..=parts.len()).rev() {+ out.push(parts.get(..n).unwrap_or_default().join("-"));+ }+ out+}++/// The registered language for a locale tag such as `ja_JP.UTF-8` or `ja-JP`; `None` when there is+/// no catalog for it. `C`/`POSIX` mean English.+pub fn lang_from_tag(tag: &str) -> Option<Lang> {+ let cands = candidates(tag);+ if matches!(cands.first().map(String::as_str), Some("c" | "posix")) {+ return Some(Lang::EN);+ }+ cands.iter().find_map(|c| Lang::from_code(c))+}++/// The first of the system's preferred languages DeckCraft has, in the user's order: someone who+/// prefers French, then Japanese, gets Japanese rather than English.+pub fn first_supported<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<Lang> {+ tags.into_iter().find_map(lang_from_tag)+}++/// The system language (looked up once). English when it can't be determined or isn't supported.+pub fn system_lang() -> Lang {+ // Tests drive the UI by its English labels whatever the machine's language is.+ if cfg!(test) {+ return Lang::EN;+ }+ static SYSTEM: OnceLock<Lang> = OnceLock::new();+ *SYSTEM.get_or_init(detect_system_lang)+}++fn detect_system_lang() -> Lang {+ // An explicit override, then the platform's preferred UI languages (Windows display language,+ // macOS preferred languages, `LC_ALL`/`LC_MESSAGES`/`LANG` on Unix, the browser on the web).+ if let Some(l) = std::env::var("DECKCRAFT_LANGUAGE").ok().and_then(|v| lang_from_tag(&v)) {+ return l;+ }+ let tags: Vec<String> = sys_locale::get_locales().take(16).collect();+ first_supported(tags.iter().map(String::as_str)).unwrap_or(Lang::EN)+}++thread_local! {+ // Per thread: parallel test harnesses must not change each other's language.+ static CURRENT: Cell<Lang> = const { Cell::new(Lang::EN) };+}++/// Set the language the UI is drawn in (the app does this every frame from its setting).+pub fn set_current(lang: Lang) {+ CURRENT.set(lang);+}++/// The language the UI is drawn in.+pub fn current() -> Lang {+ CURRENT.get()+}++/// Does `lang` have a catalog entry for this string? (English never does: it is the source.)+pub fn has(lang: Lang, s: &str) -> bool {+ lang.catalog().plain(s).is_some()+}++/// `s` in the current language ([`tr`] with [`current`]).+pub fn t(s: &str) -> &str {+ tr(current(), s)+}++/// `s` in `lang`; strings without a translation come back unchanged.+pub fn tr(lang: Lang, s: &str) -> &str {+ lang.catalog().plain(s).unwrap_or(s)+}++/// Fill `{name}` placeholders in one pass. Unknown placeholders stay as written, and inserted+/// values are never read as templates again, so a file name containing `{n}` stays intact.+pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {+ let mut out = String::with_capacity(template.len());+ let mut rest = template;+ while let Some((before, after_open)) = rest.split_once('{') {+ out.push_str(before);+ let Some((name, after_close)) = after_open.split_once('}') else {+ out.push('{');+ out.push_str(after_open);+ return out;+ };+ match args.iter().find(|(key, _)| *key == name) {+ Some((_, value)) => out.push_str(value),+ None => {+ out.push('{');+ out.push_str(name);+ out.push('}');+ }+ }+ rest = after_close;+ }+ out.push_str(rest);+ out+}++#[cfg(test)]+mod tests;new file mode 100644@@ -0,0 +1,162 @@+//! Interface language tests: catalog format, system-language detection, the setting's fallback,+//! switching the UI language and English fallback for strings without a translation.++use super::catalog::{Catalog, parse_entries, placeholders};+use super::*;++fn lang(code: &str) -> Lang {+ Lang::from_code(code).unwrap_or_else(|| panic!("{code} registered"))+}++/// A language tag no catalog will ever have: `qaa`–`qtz` are reserved for local use (ISO 639-2),+/// so these tests keep working as real languages land.+const UNSUPPORTED: &str = "qaa-QM";++#[test]+fn system_locales_map_to_languages() {+ assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(lang("ja")));+ assert_eq!(lang_from_tag("ja-JP"), Some(lang("ja")));+ assert_eq!(lang_from_tag("ja"), Some(lang("ja")));+ assert_eq!(lang_from_tag("JA-jp"), Some(lang("ja")));+ assert_eq!(lang_from_tag("en-GB"), Some(Lang::EN));+ assert_eq!(lang_from_tag("C"), Some(Lang::EN));+ assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));+ assert_eq!(lang_from_tag(UNSUPPORTED), None);+ assert_eq!(lang_from_tag(""), None);+ assert_eq!(lang_from_tag("_"), None);+}++#[test]+fn the_first_supported_preferred_language_wins() {+ // Windows and macOS list several preferred languages in order.+ assert_eq!(first_supported([UNSUPPORTED, "ja-JP", "en-US"]), Some(lang("ja")));+ assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN));+ assert_eq!(first_supported([UNSUPPORTED, "qtz"]), None);+ assert_eq!(first_supported(["qtz", "ja-JP"]), Some(lang("ja")));+ assert_eq!(first_supported([]), None);+}++#[test]+fn a_cjk_interface_asks_for_a_cjk_face() {+ assert!(lang("ja").uses_cjk(), "Japanese needs a CJK interface face");+ assert!(!Lang::EN.uses_cjk());+ assert!(lang("ja").catalog_text().contains("挿入"), "the catalog text is what the interface draws");+}++#[test]+fn settings_resolve_with_fallback() {+ assert_eq!(Lang::from_pref("ja"), lang("ja"));+ assert_eq!(Lang::from_pref("JA"), lang("ja"));+ assert_eq!(Lang::from_pref("en"), Lang::EN);+ // `auto` and unknown codes follow the system (English under test).+ assert_eq!(Lang::from_pref(AUTO), Lang::EN);+ assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN);+ assert_eq!(normalize_pref("Ja"), Some("ja"));+ assert_eq!(normalize_pref("Auto"), Some(AUTO));+ assert_eq!(normalize_pref("xx"), None);+ assert_eq!(normalize_pref("jp"), None, "only exact codes are settings");+}++#[test]+fn lookups_fall_back_to_english() {+ let ja = lang("ja");+ assert_eq!(tr(ja, "File"), "ファイル");+ assert_eq!(tr(ja, "Undo"), "元に戻す");+ assert_eq!(tr(ja, "no such label"), "no such label");+ assert_eq!(tr(Lang::EN, "File"), "File");+ assert!(has(ja, "Save") && !has(Lang::EN, "Save"));+}++#[test]+fn switching_the_interface_language_changes_what_is_drawn() {+ let ja = lang("ja");+ set_current(ja);+ assert_eq!(t("Insert"), "挿入");+ assert_eq!(t("Slide Sorter"), "スライド一覧");+ assert_eq!(fmt(t("Slide {current} of {total}"), &[("current", "3"), ("total", "9")]), "スライド 3 / 9");+ // An untranslated string stays English: coverage can grow one entry at a time.+ assert_eq!(t("A label no catalog has"), "A label no catalog has");+ set_current(Lang::EN);+ assert_eq!(t("Insert"), "Insert");+}++#[test]+fn current_language_is_per_thread() {+ set_current(lang("ja"));+ assert_eq!(t("Edit"), "編集");+ std::thread::spawn(|| assert_eq!(current(), Lang::EN)).join().unwrap();+ set_current(Lang::EN);+ assert_eq!(t("Edit"), "Edit");+}++#[test]+fn placeholders_fill_without_reinterpreting_values() {+ let args = [("path", "a-{pages}.pptx"), ("pages", "2")];+ assert_eq!(fmt("{pages}: {path}; {unknown}", &args), "2: a-{pages}.pptx; {unknown}");+ assert_eq!(fmt("text {unfinished", &args), "text {unfinished");+ assert_eq!(fmt(tr(lang("ja"), "Slide {current} of {total}"), &[("current", "3"), ("total", "9")]), "スライド 3 / 9");+}++#[test]+fn malformed_catalog_lines_are_skipped_not_fatal() {+ let (c, errors) = Catalog::parse("# c\nHello\tこんにちは\nOpen\t開く\nPage {n}\tページ\nno tabs\nHello\t重複\nLine\\nTwo\t一\\n二\n");+ // `\tHello\t...` has an empty source column; `Page {n}` keeps a different placeholder.+ assert_eq!(errors.len(), 3, "{errors:?}");+ assert_eq!(c.plain("Hello"), Some("こんにちは"), "the first entry wins");+ assert_eq!(c.plain("Line\nTwo"), Some("一\n二"));+ assert_eq!(c.plain("Page {n}"), None, "a different placeholder set is rejected");+ assert_eq!(c.plain("Open"), Some("開く"));+}++/// Every bundled catalog parses without errors, and its entries keep the English placeholders.+#[test]+fn bundled_catalogs_are_well_formed() {+ let mut codes = std::collections::HashSet::new();+ for l in &LANGUAGES {+ assert!(codes.insert(l.code), "duplicate code {}", l.code);+ if l.code == "en" {+ assert!(l.source.is_empty(), "English is the source language, not a catalog");+ continue;+ }+ let (entries, errors) = parse_entries(l.source);+ assert!(errors.is_empty(), "{}: {errors:?}", l.code);+ assert!(entries.len() > 100, "{}: {} entries", l.code, entries.len());+ for e in &entries {+ assert!(!e.source.trim().is_empty() && !e.translation.trim().is_empty(), "{}: empty entry", l.code);+ assert_eq!(placeholders(&e.source), placeholders(&e.translation), "{}: {:?}", l.code, e.source);+ }+ }+}++/// The first step's coverage: the Japanese catalog translates the menus, the ribbon's tabs and+/// buttons and the status bar, not just a handful of strings.+#[test]+fn japanese_covers_the_menus_ribbon_and_status_bar() {+ let ja = lang("ja");+ for (english, japanese) in [+ ("File", "ファイル"),+ ("Edit", "編集"),+ ("View", "表示"),+ ("Insert", "挿入"),+ ("Home", "ホーム"),+ ("Design", "デザイン"),+ ("Transitions", "画面切り替え"),+ ("Animations", "アニメーション"),+ ("Slide Show", "スライドショー"),+ ("Review", "校閲"),+ ("Undo", "元に戻す"),+ ("Redo", "やり直し"),+ ("Paste", "貼り付け"),+ ("Bold", "太字"),+ ("Italic", "斜体"),+ ("Align Left", "左揃え"),+ ("Paragraph", "段落"),+ ("Slide Sorter", "スライド一覧"),+ ("Fit slide to current window", "スライドをウィンドウに合わせる"),+ ("Interface language:", "表示言語:"),+ ("Cancel", "キャンセル"),+ ] {+ assert_eq!(tr(ja, english), japanese, "{english}");+ }+ assert_eq!(ja.translated_strings(), parse_entries(ja.0.source).0.len());+}new file mode 100644@@ -0,0 +1,81 @@+//! Interface language: switching draws the ribbon and a dialog in Japanese, an untranslated label+//! stays in English, and a damaged `language` setting falls back to following the system.++use deckcraft_engine::Session;+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;++use crate::{Services, SlideApp};++fn harness() -> Harness<'static, SlideApp> {+ let mut app = SlideApp::new(Session::with_new(), Services::default());+ app.ui.tab = "Home".into();+ let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).allow_missing_glyphs().build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(4);+ h+}++#[test]+fn switching_to_japanese_redraws_the_ribbons_buttons_in_japanese() {+ let mut h = harness();+ // English before the switch (`auto` picks English under test). The ribbon tabs are painted,+ // so the buttons are what the accessibility tree (and this test) can see.+ assert!(h.query_all_by_label("Paste").next().is_some(), "the English Paste button is drawn");+ assert!(h.query_all_by_label("貼り付け").next().is_none());+ h.state_mut().run("ui.language", json!({"value": "ja"})).unwrap();+ h.run_steps(2);+ for japanese in ["貼り付け", "レイアウト", "リセット", "セクション"] {+ assert!(h.query_all_by_label(japanese).next().is_some(), "the Japanese label {japanese} is drawn");+ }+ assert!(h.query_all_by_label("Paste").next().is_none(), "the English button label is gone");+ // Back to English: the same buttons draw in English again.+ h.state_mut().run("ui.language", json!({"value": "en"})).unwrap();+ h.run_steps(2);+ assert!(h.query_all_by_label("Paste").next().is_some());+ assert!(h.query_all_by_label("貼り付け").next().is_none());+}++#[test]+fn the_preferences_picker_switches_the_interface_language() {+ let mut h = harness();+ h.state_mut().run("app.preferences", json!({})).unwrap();+ h.run_steps(2);+ // The dialog opens in English; its Interface language list follows the system by default.+ assert!(h.query_all_by_label("Interface language:").next().is_some(), "the language section is shown");+ h.get_by_label("Automatic (English)").click_accesskit();+ h.run_steps(2);+ h.get_by_label("日本語").click();+ h.run_steps(2);+ assert!(h.query_all_by_label("表示言語:").next().is_some(), "the dialog itself is redrawn in Japanese");+ assert_eq!(h.state().ui.language, "ja");+}++#[test]+fn interface_language_setting_takes_codes_or_auto_and_rejects_junk() {+ let mut app = SlideApp::new(Session::with_new(), Services::default());+ assert_eq!(app.ui.language, "auto", "the default follows the system");+ assert!(app.run("ui.language", json!({"value": "klingon"})).is_err());+ assert_eq!(app.ui.language, "auto", "a hostile value changes nothing");+ assert!(app.run("ui.language", json!({"value": 1})).is_err());+ app.run("ui.language", json!({"value": "JA"})).unwrap();+ assert_eq!(app.ui.language, "ja", "a code is stored in its canonical form");+ app.run("ui.language", json!({"value": "auto"})).unwrap();+ assert_eq!(app.ui.language, "auto");+ // A string without a translation is drawn in English, never blank.+ assert_eq!(crate::i18n::t("A label no catalog has"), "A label no catalog has");+}++#[test]+fn a_damaged_saved_language_setting_falls_back_to_automatic() {+ let junk = crate::UiState { language: "klingon".into(), ..Default::default() }.sanitized();+ assert_eq!(junk.language, crate::i18n::AUTO);+ let code = crate::UiState { language: "JA".into(), ..Default::default() }.sanitized();+ assert_eq!(code.language, "ja");+}@@ -6,6 +6,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// An English UI string in the current interface language ([`i18n::t`]).+#[macro_export]+macro_rules! tl {+ ($s:expr) => {+ $crate::i18n::t($s)+ };+}+ pub mod backstage; pub mod canvas; pub mod capture;@@ -13,6 +21,7 @@ pub mod control; pub mod dialogs; pub mod doc_window; pub mod eyedropper;+pub mod i18n; pub mod icons; pub mod media; pub mod menus;@@ -105,6 +114,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"), ("view.tab", "Ribbon Tab", None, "{tab}"), ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"),+ // Interface language: `auto` follows the system; `en`/`ja` pick a catalog. Menus, the ribbon,+ // the status bar and command labels change; command ids, the presentation and file names don't.+ ("ui.language", "Interface Language", None, "{value: auto|en|ja}"), ("view.dark", "Dark Mode", None, "{on?: bool}"), ("view.grayscale", "Grayscale", None, "{on?: bool}"), ("view.newWindow", "New Window", None, "{on?: bool, slide?: index, delta?: int}"),@@ -466,5 +478,8 @@ mod datetime_tests; #[cfg(test)] mod inbox_tests; +#[cfg(test)]+mod language_tests;+ #[cfg(test)] mod tests;@@ -81,14 +81,24 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool { /// The palette's entries for the lowercase query `q`. An engine command is hidden when a UI command has the same label: Open…, Save As…, /// Export… and Audio/Video from File… need a `path` the palette can't give, so the one entry left opens the dialog instead of failing. fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, String, Option<&'static str>)> {+ // Search the English label (the catalog key) and the translated one, so a query typed in either+ // language finds the command; what is listed is the current interface language's label.+ let matched = |label: &str, id: &str| {+ q.is_empty() || label.to_lowercase().contains(q) || crate::i18n::t(label).to_lowercase().contains(q) || id.to_lowercase().contains(q)+ }; let mut items: Vec<(String, String, Option<&'static str>)> = session .commands() .into_iter() // Commands that need a path or other value fail when the palette runs them with `{}`. .filter(|c| c.enabled && !crate::UI_COMMANDS.iter().any(|u| u.1 == c.label) && !deckcraft_engine::cmd::needs_arguments(c.params))- .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut))- .chain(crate::UI_COMMANDS.iter().filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3)).map(|c| (c.0.to_string(), c.1.to_string(), c.2)))- .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(q) || id.to_lowercase().contains(q))+ .filter(|c| matched(c.label, c.id))+ .map(|c| (c.id.to_string(), tl!(c.label).to_string(), c.shortcut))+ .chain(+ crate::UI_COMMANDS+ .iter()+ .filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3) && matched(c.1, c.0))+ .map(|c| (c.0.to_string(), tl!(c.1).to_string(), c.2)),+ ) .collect(); items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(q)); items.truncate(60);@@ -106,7 +116,8 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 90.0)) .fixed_size(egui::vec2(520.0, 380.0)) .show(ctx, |ui| {- let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0)));+ let r = ui+ .add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search commands…")).desired_width(f32::INFINITY).font(theme::font(15.0))); r.request_focus(); let q = query.to_lowercase(); let items = palette_items(&app.session, &q);@@ -161,135 +172,138 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> { vec![ (- "File",+ tl!("File"), vec![- ("New Presentation", "file.new"),- ("Open…", "app.openDialog"),+ (tl!("New Presentation"), "file.new"),+ (tl!("Open…"), "app.openDialog"), ("-", ""),- ("Close", "app.closePresentation"),- ("Save", "file.save"),- ("Save As…", "app.saveAsDialog"),- ("Export…", "app.exportDialog"),- ("Print…", "app.print"),+ (tl!("Close"), "app.closePresentation"),+ (tl!("Save"), "file.save"),+ (tl!("Save As…"), "app.saveAsDialog"),+ (tl!("Export…"), "app.exportDialog"),+ (tl!("Print…"), "app.print"), ("-", ""),- ("Properties", "file.properties"),+ (tl!("Properties"), "file.properties"), ], ), (- "Edit",+ tl!("Edit"), vec![- ("Undo", "edit.undo"),- ("Redo", "edit.redo"),+ (tl!("Undo"), "edit.undo"),+ (tl!("Redo"), "edit.redo"), ("-", ""),- ("Cut", "edit.cut"),- ("Copy", "edit.copy"),- ("Paste", "edit.paste"),- ("Paste and Match Formatting", "edit.pasteText"),- ("Paste Special…", "app.pasteSpecial"),+ (tl!("Cut"), "edit.cut"),+ (tl!("Copy"), "edit.copy"),+ (tl!("Paste"), "edit.paste"),+ (tl!("Paste and Match Formatting"), "edit.pasteText"),+ (tl!("Paste Special…"), "app.pasteSpecial"), ("-", ""),- ("Select All", "edit.selectAll"),- ("Duplicate", "edit.duplicate"),- ("Delete Slide", "slide.delete"),+ (tl!("Select All"), "edit.selectAll"),+ (tl!("Duplicate"), "edit.duplicate"),+ (tl!("Delete Slide"), "slide.delete"), ("-", ""),- ("Find…", "app.find"),- ("Replace…", "app.replace"),+ (tl!("Find…"), "app.find"),+ (tl!("Replace…"), "app.replace"), ], ), (- "View",+ tl!("View"), vec![- ("Normal", "view.normal"),- ("Slide Sorter", "view.sorter"),- ("Notes Page", "view.notesPage"),- ("Outline View", "view.outline"),- ("Reading View", "view.reading"),- ("Slide Show", "show.start"),+ (tl!("Normal"), "view.normal"),+ (tl!("Slide Sorter"), "view.sorter"),+ (tl!("Notes Page"), "view.notesPage"),+ (tl!("Outline View"), "view.outline"),+ (tl!("Reading View"), "view.reading"),+ (tl!("Slide Show"), "show.start"), ("-", ""),- ("Slide Master", "view.slideMaster"),- ("Handout Master", "view.handoutMaster"),- ("Notes Master", "view.notesMaster"),+ (tl!("Slide Master"), "view.slideMaster"),+ (tl!("Handout Master"), "view.handoutMaster"),+ (tl!("Notes Master"), "view.notesMaster"), ("-", ""),- ("Ruler", "view.ruler"),- ("Gridlines", "view.gridlines"),- ("Guides", "view.guides"),- ("Grid Options…", "view.gridOptions"),+ (tl!("Ruler"), "view.ruler"),+ (tl!("Gridlines"), "view.gridlines"),+ (tl!("Guides"), "view.guides"),+ (tl!("Grid Options…"), "view.gridOptions"), ("-", ""),- ("Zoom…", "view.zoomDialog"),- ("Zoom In", "view.zoomIn"),- ("Zoom Out", "view.zoomOut"),- ("Fit to Window", "view.fit"),+ (tl!("Zoom…"), "view.zoomDialog"),+ (tl!("Zoom In"), "view.zoomIn"),+ (tl!("Zoom Out"), "view.zoomOut"),+ (tl!("Fit to Window"), "view.fit"), ("-", ""),- ("New Window", "view.newWindow"),+ (tl!("New Window"), "view.newWindow"), ], ), (- "Insert",+ tl!("Insert"), vec![- ("New Slide", "slide.new"),- ("Duplicate Slide", "slide.duplicate"),+ (tl!("New Slide"), "slide.new"),+ (tl!("Duplicate Slide"), "slide.duplicate"), ("-", ""),- ("Section", "section.add"),- ("Text Box", "insert.textBox"),- ("WordArt", "insert.wordArt"),- ("Date and Time", "insert.dateTime"),- ("Slide Number", "insert.slideNumber"),+ (tl!("Section"), "section.add"),+ (tl!("Text Box"), "insert.textBox"),+ (tl!("WordArt"), "insert.wordArt"),+ (tl!("Date and Time"), "insert.dateTime"),+ (tl!("Slide Number"), "insert.slideNumber"), ("-", ""),- ("Table…", "insert.table"),- ("Chart", "insert.chart"),- ("Picture from File…", "app.insertPictureDialog"),- ("Photo Album…", "insert.photoAlbum"),- ("Edit Photo Album…", "insert.photoAlbumEdit"),- ("Audio from File…", "app.insertAudioDialog"),- ("Video from File…", "app.insertVideoDialog"),+ (tl!("Table…"), "insert.table"),+ (tl!("Chart"), "insert.chart"),+ (tl!("Picture from File…"), "app.insertPictureDialog"),+ (tl!("Photo Album…"), "insert.photoAlbum"),+ (tl!("Edit Photo Album…"), "insert.photoAlbumEdit"),+ (tl!("Audio from File…"), "app.insertAudioDialog"),+ (tl!("Video from File…"), "app.insertVideoDialog"), ], ), (- "Format",+ tl!("Format"), vec![- ("Font…", "format.fontDialog"),- ("Paragraph…", "format.paragraphDialog"),- ("Bold", "format.bold"),- ("Italic", "format.italic"),- ("Underline", "format.underline"),+ (tl!("Font…"), "format.fontDialog"),+ (tl!("Paragraph…"), "format.paragraphDialog"),+ (tl!("Bold"), "format.bold"),+ (tl!("Italic"), "format.italic"),+ (tl!("Underline"), "format.underline"), ("-", ""),- ("Align Left", "format.alignLeft"),- ("Center", "format.alignCenter"),- ("Align Right", "format.alignRight"),- ("Justify", "format.justify"),+ (tl!("Align Left"), "format.alignLeft"),+ (tl!("Center"), "format.alignCenter"),+ (tl!("Align Right"), "format.alignRight"),+ (tl!("Justify"), "format.justify"), ("-", ""),- ("Size and Position…", "shape.sizePosition"),- ("Right-to-Left Text Direction", "format.rtl"),+ (tl!("Size and Position…"), "shape.sizePosition"),+ (tl!("Right-to-Left Text Direction"), "format.rtl"), ], ), (- "Arrange",+ tl!("Arrange"), vec![- ("Bring to Front", "arrange.bringToFront"),- ("Send to Back", "arrange.sendToBack"),- ("Bring Forward", "arrange.bringForward"),- ("Send Backward", "arrange.sendBackward"),+ (tl!("Bring to Front"), "arrange.bringToFront"),+ (tl!("Send to Back"), "arrange.sendToBack"),+ (tl!("Bring Forward"), "arrange.bringForward"),+ (tl!("Send Backward"), "arrange.sendBackward"), ("-", ""),- ("Group", "arrange.group"),- ("Ungroup", "arrange.ungroup"),- ("Regroup", "arrange.regroup"),+ (tl!("Group"), "arrange.group"),+ (tl!("Ungroup"), "arrange.ungroup"),+ (tl!("Regroup"), "arrange.regroup"), ("-", ""),- ("Rotate Left 90°", "arrange.rotateLeft"),- ("Rotate Right 90°", "arrange.rotateRight"),- ("Flip Horizontal", "arrange.flipHorizontal"),- ("Flip Vertical", "arrange.flipVertical"),+ (tl!("Rotate Left 90°"), "arrange.rotateLeft"),+ (tl!("Rotate Right 90°"), "arrange.rotateRight"),+ (tl!("Flip Horizontal"), "arrange.flipHorizontal"),+ (tl!("Flip Vertical"), "arrange.flipVertical"), ], ), (- "Slide Show",+ tl!("Slide Show"), vec![- ("Play from Start", "show.fromStart"),- ("Play from Current Slide", "show.fromCurrent"),+ (tl!("Play from Start"), "show.fromStart"),+ (tl!("Play from Current Slide"), "show.fromCurrent"), ("-", ""),- ("Hide Slide", "slide.hide"),- ("Set Up Show…", "show.setup"),+ (tl!("Hide Slide"), "slide.hide"),+ (tl!("Set Up Show…"), "show.setup"), ], ),- ("Help", vec![("About DeckCraft", "app.about"), ("Check for Updates…", "app.checkUpdates"), ("Command Palette", "app.palette")]),+ (+ tl!("Help"),+ vec![(tl!("About DeckCraft"), "app.about"), (tl!("Check for Updates…"), "app.checkUpdates"), (tl!("Command Palette"), "app.palette")],+ ), ] } @@ -134,23 +134,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let mut x = rect.min.x + 12.0; let y = rect.min.y + 2.0; {- let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;+ let file = tl!("File");+ let w = ui.fonts_mut(|f| f.layout_no_wrap(file.to_string(), theme::font(13.0), t.text).size().x) + 18.0; let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); let resp = ui.interact(r, ui.scope_id().with("tab_file"), Sense::click());- resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "File"));+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, file)); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); }- ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text);- if resp.on_hover_text("File (Alt+F)").clicked() {+ ui.painter().text(r.center(), Align2::CENTER_CENTER, file, theme::font(13.0), t.text);+ if resp.on_hover_text(tl!("File (Alt+F)")).clicked() { app.open_backstage(crate::backstage::Page::Home); } x += w + 2.0; } let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect(); for (name, contextual) in all {+ // The tab id stays English (`app.ui.tab`); only what is painted is translated.+ let shown = tl!(name); let font = theme::font(13.0);- let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0;+ let w = ui.fonts_mut(|f| f.layout_no_wrap(shown.to_string(), font.clone(), t.text).size().x) + 18.0; let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); let resp = ui.interact(r, ui.scope_id().with(("tab", name)), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));@@ -160,7 +163,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } let col = if contextual { t.contextual } else { t.text }; let f = if active { theme::bold(13.0) } else { font };- ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col);+ ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, f, col); if active { let uw = (w - 20.0).max(16.0); ui.painter().rect_filled(Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(uw, 3.0)), CornerRadius::same(1), t.accent);@@ -279,7 +282,7 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va let en = enabled(app, id); let r = big_button(ui, icon, label, en); if id == "insert.dateTime" {- let name = label.replace('\n', " ");+ let name = crate::i18n::t(label).replace('\n', " "); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, &name)); } let r = r.on_hover_text(tip(id, label));@@ -299,6 +302,7 @@ fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: } fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on: bool) {+ let label = crate::i18n::t(label); let en = enabled(app, id); if icon_toggle(ui, icon, &tip(id, label), en, on).clicked() { run(app, id, json!({}));@@ -14,7 +14,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let playing = app.anim_preview_active(); let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing);- if resp.on_hover_text("Play this slide's animations on the canvas").clicked() {+ if resp.on_hover_text(tl!("Play this slide's animations on the canvas")).clicked() { run(app, "animation.preview", json!({})); } });@@ -122,7 +122,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); egui::Popup::menu(&r).show(|ui| {- if ui.button("On Click of… (none)").clicked() {+ if ui.button(tl!("On Click of… (none)")).clicked() { run(app, "animation.timing", json!({"trigger": null})); } let shapes: Vec<(u32, String)> =@@ -136,7 +136,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { let on = app.session.anim_painter.is_some(); let en = enabled(app, "animation.painter"); let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on);- let r = r.on_hover_text("Animation Painter (double-click to keep it on)");+ let r = r.on_hover_text(tl!("Animation Painter (double-click to keep it on)")); if r.double_clicked() { run(app, "animation.painter", json!({"sticky": true})); } else if r.clicked() {@@ -150,7 +150,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { ui.horizontal(|ui| {- ui.label("Start:");+ ui.label(tl!("Start:")); let s = cur.as_ref().map(|c| match c.start { deckcraft_model::AnimStart::OnClick => "On Click", deckcraft_model::AnimStart::WithPrevious => "With Previous",@@ -166,17 +166,17 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { }); }); ui.horizontal(|ui| {- ui.label("Duration:");+ ui.label(tl!("Duration:")); if let Some(v) =- widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0)+ widgets::spinner(ui, "adur", tl!("Duration"), cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) { run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); } }); ui.horizontal(|ui| {- ui.label("Delay:");+ ui.label(tl!("Delay:")); if let Some(v) =- widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0)+ widgets::spinner(ui, "adelay", tl!("Delay"), cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) { run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); }@@ -17,14 +17,14 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); ui.separator(); ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0)));- ui.menu_button("Align", |ui| align_menu(app, ui));- ui.menu_button("Rotate", |ui| {+ ui.menu_button(tl!("Align"), |ui| align_menu(app, ui));+ ui.menu_button(tl!("Rotate"), |ui| { menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); });- if ui.button("Selection Pane…").clicked() {+ if ui.button(tl!("Selection Pane…")).clicked() { let _ = app.run("view.pane", json!({"pane": "selection"})); ui.close(); }@@ -130,8 +130,8 @@ pub(super) fn size_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { let Some(x) = bx else {- ui.label("Height:");- ui.label("Width:");+ ui.label(tl!("Height:"));+ ui.label(tl!("Width:")); return; }; let len = app.session.prefs.units;@@ -139,19 +139,19 @@ pub(super) fn size_group(app: &mut SlideApp, ui: &mut Ui) { // " cm" needs more room than the inch mark. let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; ui.horizontal(|ui| {- ui.label("Height:");- if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) {+ ui.label(tl!("Height:"));+ if let Some(v) = widgets::spinner(ui, "sh", tl!("Height"), x.h / ppu, 0.1, suffix, width) { run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); } }); ui.horizontal(|ui| {- ui.label("Width: ");- if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) {+ ui.label(tl!("Width: "));+ if let Some(v) = widgets::spinner(ui, "sw", tl!("Width"), x.w / ppu, 0.1, suffix, width) { run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); } }); });- if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() {+ if ui.add_enabled(bx.is_some(), egui::Button::new(tl!("Size and Position…"))).clicked() { run(app, "shape.sizePosition", json!({})); } });@@ -75,7 +75,7 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) { }); } ui.separator();- if ui.button("Customize Colors…").clicked() {+ if ui.button(tl!("Customize Colors…")).clicked() { run(app, "design.colors", json!({})); ui.close(); }@@ -89,7 +89,7 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator();- if ui.button("Customize Fonts…").clicked() {+ if ui.button(tl!("Customize Fonts…")).clicked() { run(app, "design.fonts", json!({})); ui.close(); }@@ -132,7 +132,7 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) { } } });- if ui.button("Reset Slide Background").clicked() {+ if ui.button(tl!("Reset Slide Background")).clicked() { run(app, "design.background", json!({"reset": true})); } });@@ -140,13 +140,13 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); egui::Popup::menu(&r).show(|ui| {- if ui.button("Standard (4:3)").clicked() {+ if ui.button(tl!("Standard (4:3)")).clicked() { run(app, "design.slideSize", json!({"preset": "standard"})); }- if ui.button("Widescreen (16:9)").clicked() {+ if ui.button(tl!("Widescreen (16:9)")).clicked() { run(app, "design.slideSize", json!({"preset": "widescreen"})); }- if ui.button("Page Setup…").clicked() {+ if ui.button(tl!("Page Setup…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); } });@@ -33,13 +33,13 @@ pub(super) fn draw(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink);- if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() {+ if r.on_hover_text(tl!("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes")).clicked() { app.session.set_tool(ToolKind::Select); run(app, "draw.inkToShape", json!({})); } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false);- let _ = r.on_hover_text("Ink to Text (coming soon)");+ let _ = r.on_hover_text(tl!("Ink to Text (coming soon)")); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false);- let _ = r.on_hover_text("Ink to Math (coming soon)");+ let _ = r.on_hover_text(tl!("Ink to Math (coming soon)")); }); }@@ -94,7 +94,7 @@ pub(super) fn smartart_gallery( Some(t) => { ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current)) }- None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)),+ None => ui.add_sized([96.0, 60.0], egui::Button::new(tl!("")).selected(*k == current)), }; ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label));@@ -233,7 +233,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color None => run(app, "shape.line", json!({"none": true})), } }- ui.menu_button("Weight", |ui| {+ ui.menu_button(tl!("Weight"), |ui| { for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { if ui.button(format!("{w} pt")).clicked() { run(app, "shape.line", json!({"width": w}));@@ -241,7 +241,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color } } });- ui.menu_button("Dashes", |ui| {+ ui.menu_button(tl!("Dashes"), |ui| { for (l, d) in [ ("Solid", "solid"), ("Round Dot", "sysDot"),@@ -257,7 +257,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color } } });- ui.menu_button("Arrows", |ui| {+ ui.menu_button(tl!("Arrows"), |ui| { for (l, h, tl) in [ ("None", "none", "none"), ("Arrow at end", "none", "triangle"),@@ -275,7 +275,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color } pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) {- ui.menu_button("Shadow", |ui| {+ ui.menu_button(tl!("Shadow"), |ui| { for (l, v) in [ ("No Shadow", "none"), ("Outer", "outer"),@@ -289,15 +289,15 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { } } });- ui.menu_button("Reflection", |ui| {+ ui.menu_button(tl!("Reflection"), |ui| { for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { if ui.button(l).clicked() { run(app, "shape.effects", json!({"reflection": v})); } } });- ui.menu_button("Glow", |ui| {- if ui.button("No Glow").clicked() {+ ui.menu_button(tl!("Glow"), |ui| {+ if ui.button(tl!("No Glow")).clicked() { run(app, "shape.effects", json!({"glow": null})); } for r in [5.0, 8.0, 11.0, 18.0] {@@ -306,14 +306,14 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { } } });- ui.menu_button("Soft Edges", |ui| {+ ui.menu_button(tl!("Soft Edges"), |ui| { for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { run(app, "shape.effects", json!({"softEdges": r})); } } });- ui.menu_button("Bevel", |ui| {+ ui.menu_button(tl!("Bevel"), |ui| { for b in [ "none", "circle",@@ -334,7 +334,7 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { } } });- if ui.button("Format Shape…").clicked() {+ if ui.button(tl!("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); } }@@ -7,21 +7,21 @@ use super::*; fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false);- if main.on_hover_text("Paste (⌘V)").clicked() {+ if main.on_hover_text(tl!("Paste (⌘V)")).clicked() { run(app, "edit.paste", json!({})); } if let Some(a) = arrow { egui::Popup::menu(&a).show(|ui| {- if ui.button("Paste").clicked() {+ if ui.button(tl!("Paste")).clicked() { run(app, "edit.paste", json!({})); }- if ui.button("Paste and Match Formatting").clicked() {+ if ui.button(tl!("Paste and Match Formatting")).clicked() { run(app, "edit.pasteText", json!({})); }- if ui.button("Paste Special…").clicked() {+ if ui.button(tl!("Paste Special…")).clicked() { run(app, "app.pasteSpecial", json!({})); }- if ui.button("Paste Formatting").clicked() {+ if ui.button(tl!("Paste Formatting")).clicked() { run(app, "format.painterApply", json!({})); } });@@ -31,7 +31,7 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); let on = app.session.painter.is_some(); let en = enabled(app, "format.painter");- let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)");+ let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text(tl!("Format Painter (double-click to keep it on)")); if r.double_clicked() { run(app, "format.painter", json!({"sticky": true})); } else if r.clicked() {@@ -97,7 +97,7 @@ pub(super) fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { fn slides_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false);- if main.on_hover_text("New Slide (⇧⌘N)").clicked() {+ if main.on_hover_text(tl!("New Slide (⇧⌘N)")).clicked() { run(app, "slide.new", json!({})); } if let Some(a) = arrow {@@ -121,10 +121,10 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) { fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { layout_menu(app, ui, "slide.new"); ui.separator();- if ui.button("Duplicate Selected Slides").clicked() {+ if ui.button(tl!("Duplicate Selected Slides")).clicked() { run(app, "slide.duplicate", json!({})); }- if ui.button("Slides from Outline…").clicked() {+ if ui.button(tl!("Slides from Outline…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("outline")); } let desktop = app.services.pick_open.is_some();@@ -139,10 +139,10 @@ fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { /// The Section split button's menu. fn section_menu(app: &mut SlideApp, ui: &mut Ui) {- if ui.button("Add Section").clicked() {+ if ui.button(tl!("Add Section")).clicked() { run(app, "section.add", json!({})); }- if ui.button("Remove All Sections").clicked() {+ if ui.button(tl!("Remove All Sections")).clicked() { run(app, "section.removeAll", json!({})); } }@@ -187,7 +187,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } ui.separator(); // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts).- if ui.button("Rescan Installed Fonts").clicked() {+ if ui.button(tl!("Rescan Installed Fonts")).clicked() { run(app, "font.rescan", json!({})); ui.close(); }@@ -256,7 +256,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } });- if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() {+ if ui.add_enabled(en, egui::Button::new(tl!("Font…")).min_size(vec2(52.0, 22.0))).on_hover_text(tl!("Font")).clicked() { run(app, "format.fontDialog", json!({})); } });@@ -272,13 +272,13 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 1.0; let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);- let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true);- if m.on_hover_text("Bullets").clicked() {+ let (m, a) = small_button_named(ui, Icon::Bullets, "", tl!("Bullets"), en, b("bullets"), true);+ if m.on_hover_text(tl!("Bullets")).clicked() { run(app, "format.bullets", json!({})); } if let Some(a) = a { egui::Popup::menu(&a).show(|ui| {- if ui.button("None").clicked() {+ if ui.button(tl!("None")).clicked() { run(app, "format.bullets", json!({"on": false})); } for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] {@@ -287,20 +287,20 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } ui.separator();- if ui.button("Bullets and Numbering…").clicked() {+ if ui.button(tl!("Bullets and Numbering…")).clicked() { let mut dlg = crate::dialogs::Dialog::new("bullets"); dlg.fields.insert("tab".into(), "bulleted".into()); app.dialog = Some(dlg); } }); }- let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true);- if m.on_hover_text("Numbering").clicked() {+ let (m, a) = small_button_named(ui, Icon::Numbering, "", tl!("Numbering"), en, b("numbering"), true);+ if m.on_hover_text(tl!("Numbering")).clicked() { run(app, "format.numbering", json!({})); } if let Some(a) = a { egui::Popup::menu(&a).show(|ui| {- if ui.button("None").clicked() {+ if ui.button(tl!("None")).clicked() { run(app, "format.numbering", json!({"on": false})); } for (label, sch) in [@@ -317,7 +317,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } ui.separator();- if ui.button("Bullets and Numbering…").clicked() {+ if ui.button(tl!("Bullets and Numbering…")).clicked() { let mut dlg = crate::dialogs::Dialog::new("bullets"); dlg.fields.insert("tab".into(), "numbered".into()); app.dialog = Some(dlg);@@ -333,10 +333,10 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.lineSpacing", json!({"lines": v})); } }- if ui.button("Line Spacing Options…").clicked() {+ if ui.button(tl!("Line Spacing Options…")).clicked() { run(app, "format.paragraphDialog", json!({})); }- if ui.button("Tabs…").clicked() {+ if ui.button(tl!("Tabs…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("tabs")); } });@@ -383,7 +383,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None));- if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() {+ if ui.add_enabled(en, egui::Button::new(tl!("Paragraph…")).min_size(vec2(88.0, 22.0))).on_hover_text(tl!("Paragraph")).clicked() { run(app, "format.paragraphDialog", json!({})); } });@@ -395,7 +395,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let en = enabled(app, "shape.insert");- let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes");+ let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text(tl!("Shapes")); egui::Popup::menu(&r).show(|ui| { if let Some(p) = shapes_gallery(ui) { app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() });@@ -8,10 +8,10 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { let en = enabled(app, "insert.photoAlbum"); let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…"));- main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…"));+ main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, tl!("Photo Album…"))); if let Some(a) = &arrow {- let _ = a.clone().on_hover_text("Photo Album options");- a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options"));+ let _ = a.clone().on_hover_text(tl!("Photo Album options"));+ a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, tl!("Photo Album options"))); } if main.clicked() { run(app, "insert.photoAlbum", json!({}));@@ -44,11 +44,11 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } ui.separator();- if ui.button("Insert Table…").clicked() {+ if ui.button(tl!("Insert Table…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("table")); ui.close(); }- if ui.button("Draw Table").clicked() {+ if ui.button(tl!("Draw Table")).clicked() { app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); ui.close(); }@@ -58,7 +58,7 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); photo_album_button(app, ui); let r = big_button(ui, Icon::Screenshot, "Screenshot", false);- let _ = r.on_hover_text("Screenshot (not available yet)");+ let _ = r.on_hover_text(tl!("Screenshot (not available yet)")); }); group(ui, |ui| { let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert"));@@ -97,7 +97,7 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) { } ui.separator(); let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty());- if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() {+ if ui.add_enabled(has_sel, egui::Button::new(tl!("Action Settings…"))).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("action")); } });@@ -188,7 +188,7 @@ fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_eng } }); if n == 0 {- ui.label("No icons match.");+ ui.label(tl!("No icons match.")); } } @@ -23,15 +23,15 @@ pub(super) fn slide_master(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { let (preserve, title, footers) = master_checks(app); let mut on = preserve;- if ui.checkbox(&mut on, "Preserve").changed() {+ if ui.checkbox(&mut on, tl!("Preserve")).changed() { run(app, "master.preserve", json!({"preserve": on})); } let mut on = title;- if ui.checkbox(&mut on, "Title").changed() {+ if ui.checkbox(&mut on, tl!("Title")).changed() { run(app, "master.placeholders", json!({"title": on})); } let mut on = footers;- if ui.checkbox(&mut on, "Footers").changed() {+ if ui.checkbox(&mut on, tl!("Footers")).changed() { run(app, "master.placeholders", json!({"footers": on})); } });@@ -57,7 +57,7 @@ pub(super) fn slide_master(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator();- if ui.button("Browse for Themes…").clicked() {+ if ui.button(tl!("Browse for Themes…")).clicked() { run(app, "app.browseThemeDialog", json!({})); } });@@ -36,7 +36,7 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator();- ui.label("Recolor");+ ui.label(tl!("Recolor")); let sc = scheme(app); let presets = deckcraft_engine::cmd::recolor::presets(); let names: Vec<String> = presets.iter().map(|(_, l)| l.clone()).collect();@@ -58,14 +58,15 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); // As in PowerPoint, the pointer picks the colour in the picture itself: a colour // chosen from a grid almost never matches a photo's pixels.- if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() {+ let hover = tl!("Click a colour in the picture to make it transparent");+ if ui.button(tl!("Set Transparent Color")).on_hover_text(hover).clicked() { crate::eyedropper::request_transparent_color(ui.ctx()); ui.close(); } }); let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); egui::Popup::menu(&r).show(|ui| {- if ui.button("None").clicked() {+ if ui.button(tl!("None")).clicked() { run(app, "picture.artisticEffect", json!({"effect": "none"})); } for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS {@@ -123,14 +124,14 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator();- ui.menu_button("Weight", |ui| {+ ui.menu_button(tl!("Weight"), |ui| { for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { if ui.button(format!("{w} pt")).clicked() { run(app, "shape.line", json!({"width": w})); } } });- ui.menu_button("Dashes", |ui| {+ ui.menu_button(tl!("Dashes"), |ui| { for (l, d) in [ ("Solid", "solid"), ("Round Dot", "sysDot"),@@ -145,7 +146,7 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } }); });- if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() {+ if ui.small_button(tl!("Reset Style")).on_hover_text(tl!("Remove the picture style")).clicked() { run(app, "picture.style", json!({"style": "none"})); } big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"}));@@ -154,7 +155,7 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::Crop, "Crop", true); egui::Popup::menu(&r).show(|ui| {- ui.label("Crop to Shape");+ ui.label(tl!("Crop to Shape")); if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); }@@ -246,8 +247,8 @@ fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { let id = egui::Id::unique("compress_pictures_opts"); let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); ui.label(egui::RichText::new("Compression options").strong());- ui.checkbox(&mut only_selected, "Apply only to this picture");- ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures");+ ui.checkbox(&mut only_selected, tl!("Apply only to this picture"));+ ui.checkbox(&mut delete_cropped, tl!("Delete cropped areas of pictures")); ui.separator(); ui.label(egui::RichText::new("Resolution").strong()); for (v, label) in [@@ -260,7 +261,7 @@ fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { } ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); ui.separator();- if ui.button("Compress").clicked() {+ if ui.button(tl!("Compress")).clicked() { let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); match app.session.execute("picture.compress", &p) { Ok(r) => {@@ -15,29 +15,29 @@ pub(super) fn playback(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { let mut auto = m.as_ref().is_some_and(|x| x.autoplay);- if ui.checkbox(&mut auto, "Start Automatically").changed() {+ if ui.checkbox(&mut auto, tl!("Start Automatically")).changed() { run(app, "media.options", json!({"autoplay": auto})); } let mut across = m.as_ref().is_some_and(|x| x.play_across_slides);- if ui.checkbox(&mut across, "Play Across Slides").changed() {+ if ui.checkbox(&mut across, tl!("Play Across Slides")).changed() { run(app, "media.options", json!({"acrossSlides": across})); } let mut lp = m.as_ref().is_some_and(|x| x.loop_play);- if ui.checkbox(&mut lp, "Loop until Stopped").changed() {+ if ui.checkbox(&mut lp, tl!("Loop until Stopped")).changed() { run(app, "media.options", json!({"loop": lp})); } }); rows(ui, |ui| { let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing);- if ui.checkbox(&mut hide, "Hide During Show").changed() {+ if ui.checkbox(&mut hide, tl!("Hide During Show")).changed() { run(app, "media.options", json!({"hide": hide})); } let mut rw = m.as_ref().is_some_and(|x| x.rewind);- if ui.checkbox(&mut rw, "Rewind after Playing").changed() {+ if ui.checkbox(&mut rw, tl!("Rewind after Playing")).changed() { run(app, "media.options", json!({"rewind": rw})); } let mut fs = m.as_ref().is_some_and(|x| x.full_screen);- if ui.checkbox(&mut fs, "Play Full Screen").changed() {+ if ui.checkbox(&mut fs, tl!("Play Full Screen")).changed() { run(app, "media.options", json!({"fullScreen": fs})); } });@@ -102,11 +102,12 @@ fn run(app: &mut SlideApp, q: &QatItem) { } fn tip(q: &QatItem) -> String {+ let label = crate::i18n::t(&q.label); let sc = deckcraft_engine::find_command(&q.id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == q.id).and_then(|c| c.2)); match sc {- Some(s) => format!("{} ({})", q.label, crate::ribbon::pretty_shortcut(s)),- None => q.label.clone(),+ Some(s) => format!("{label} ({})", crate::ribbon::pretty_shortcut(s)),+ None => label.to_string(), } } @@ -125,7 +126,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { run(app, q); } r.context_menu(|ui| {- if ui.button("Remove from Quick Access Toolbar").clicked() {+ if ui.button(tl!("Remove from Quick Access Toolbar")).clicked() { let _ = command(app, &json!({"remove": q.id, "params": q.params})); ui.close(); }@@ -133,10 +134,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, "Customize Quick Access Toolbar", true, false); egui::Popup::menu(&r).show(|ui| {- ui.label(egui::RichText::new("Customize Quick Access Toolbar").strong());+ ui.label(egui::RichText::new(tl!("Customize Quick Access Toolbar")).strong()); for c in common() { let mut on = app.ui.qat.iter().any(|q| q.same(&c.id, &Value::Null));- if ui.checkbox(&mut on, &c.label).changed() {+ if ui.checkbox(&mut on, crate::i18n::t(&c.label)).changed() { let key = if on { "add" } else { "remove" }; let _ = command(app, &json!({key: c.id})); }@@ -146,14 +147,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); for q in extra { let mut on = true;- if ui.checkbox(&mut on, &q.label).changed() {+ if ui.checkbox(&mut on, crate::i18n::t(&q.label)).changed() { let _ = command(app, &json!({"remove": q.id, "params": q.params})); } } } ui.separator();- ui.label(egui::RichText::new("Right-click any ribbon button to add it.").weak());- if ui.button("Reset to Defaults").clicked() {+ ui.label(egui::RichText::new(tl!("Right-click any ribbon button to add it.")).weak());+ if ui.button(tl!("Reset to Defaults")).clicked() { let _ = command(app, &json!({"reset": true})); ui.close(); }@@ -164,7 +165,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { pub fn ribbon_menu(app: &mut SlideApp, r: &egui::Response, icon: Icon, label: &str, id: &str, p: &Value) { r.context_menu(|ui| { let has = app.ui.qat.iter().any(|q| q.same(id, p));- let text = if has { "Remove from Quick Access Toolbar" } else { "Add to Quick Access Toolbar" };+ let text = if has { tl!("Remove from Quick Access Toolbar") } else { tl!("Add to Quick Access Toolbar") }; if ui.button(text).clicked() { let label = label.replace('\n', " "); let key = if has { "remove" } else { "add" };@@ -27,7 +27,7 @@ pub(super) fn review(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let r = big_button(ui, Icon::Translate, "Translate", false);- let _ = r.on_hover_text("Translation needs a language model (not bundled)");+ let _ = r.on_hover_text(tl!("Translation needs a language model (not bundled)")); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); group(ui, |ui| {@@ -8,7 +8,7 @@ use super::*; fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { let current = selected_warp(app); ui.label(egui::RichText::new("Transform").font(theme::bold(12.0)));- ui.label("Follow Path");+ ui.label(tl!("Follow Path")); let mut picked = false; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0);@@ -19,7 +19,7 @@ fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { } } });- ui.label("Warp");+ ui.label(tl!("Warp")); egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0);@@ -117,12 +117,12 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); egui::Popup::menu(&r).show(|ui| {- ui.label("Change Shape");+ ui.label(tl!("Change Shape")); if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } ui.separator();- if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() {+ if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new(tl!("Edit Points"))).clicked() { run(app, "shape.editPoints", json!({"on": true})); ui.close(); }@@ -130,7 +130,7 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) { smooth_corners_row(app, ui); }); let can_merge = enabled(app, "shape.merge");- let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes");+ let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text(tl!("Merge Shapes")); egui::Popup::menu(&r).show(|ui| { for (op, label) in deckcraft_engine::cmd::merge::OPS { if ui.button(label).clicked() {@@ -160,7 +160,7 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) { None => run(app, "shape.fill", json!({"none": true})), } }- if ui.button("Gradient…").clicked() {+ if ui.button(tl!("Gradient…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); ui.close(); }@@ -204,7 +204,7 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) { } let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); egui::Popup::menu(&r).width(312.0).show(|ui| {- if ui.button("Shadow").clicked() {+ if ui.button(tl!("Shadow")).clicked() { run(app, "format.textShadow", json!({})); ui.close(); }@@ -250,7 +250,7 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { } let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); ui.horizontal(|ui| {- let label = ui.label("Smooth Corners");+ let label = ui.label(tl!("Smooth Corners")); let mut pct = state.1; let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") };@@ -32,20 +32,20 @@ pub(super) fn slide_show(app: &mut SlideApp, ui: &mut Ui) { } } let r = big_button(ui, Icon::Record, "Record", false);- let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)");+ let _ = r.on_hover_text(tl!("Recording narration needs audio capture (coming soon)")); }); group(ui, |ui| { rows(ui, |ui| { let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings);- if ui.checkbox(&mut use_t, "Use Timings").changed() {+ if ui.checkbox(&mut use_t, tl!("Use Timings")).changed() { run(app, "show.setup", json!({"useTimings": use_t})); } let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration);- if ui.checkbox(&mut narr, "Play Narrations").changed() {+ if ui.checkbox(&mut narr, tl!("Play Narrations")).changed() { run(app, "show.setup", json!({"noNarration": !narr})); } let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls);- if ui.checkbox(&mut mc, "Show Media Controls").changed() {+ if ui.checkbox(&mut mc, tl!("Show Media Controls")).changed() { run(app, "show.setup", json!({"showMediaControls": mc})); } });@@ -55,7 +55,7 @@ pub(super) fn slide_show(app: &mut SlideApp, ui: &mut Ui) { pub(super) fn record(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::Cameo, "Cameo", false);- let _ = r.on_hover_text("Camera capture is not available yet");+ let _ = r.on_hover_text(tl!("Camera capture is not available yet")); if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { app.start_show(app.show_start_index(), false); if let Some(s) = app.show.as_mut() {@@ -12,7 +12,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }; let at = row.min(n.saturating_sub(1)); group(ui, |ui| {- if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() {+ if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text(tl!("Type and organise the diagram's text")).clicked() { run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); } rows(ui, |ui| {@@ -42,7 +42,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }); let pictures = kind.starts_with("picture"); if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false)- .on_hover_text("Put a picture in the selected node's picture placeholder")+ .on_hover_text(tl!("Put a picture in the selected node's picture placeholder")) .clicked() { run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at}));@@ -59,10 +59,10 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); if let Some(arrow) = arrow { widgets::split_menu(&main, &arrow, |ui| {- if ui.button("Convert to Text").clicked() {+ if ui.button(tl!("Convert to Text")).clicked() { run(app, "smartArt.toText", json!({"id": gid})); }- if ui.button("Convert to Shapes").clicked() {+ if ui.button(tl!("Convert to Shapes")).clicked() { run(app, "smartArt.toShapes", json!({"id": gid})); } });@@ -89,7 +89,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { for (k, label) in deckcraft_engine::cmd::smartart::STYLES { if k == "polished" { ui.separator();- ui.label("3-D");+ ui.label(tl!("3-D")); } let on = style == k || (style.is_empty() && k == "flat"); if ui.selectable_label(on, label).clicked() {@@ -12,31 +12,31 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); ui.horizontal(|ui| { let mut a = flags.0;- if ui.checkbox(&mut a, "Header Row").changed() {+ if ui.checkbox(&mut a, tl!("Header Row")).changed() { run(app, "table.options", json!({"headerRow": a})); } let mut d = flags.3;- if ui.checkbox(&mut d, "First Column").changed() {+ if ui.checkbox(&mut d, tl!("First Column")).changed() { run(app, "table.options", json!({"firstColumn": d})); } }); ui.horizontal(|ui| { let mut b = flags.1;- if ui.checkbox(&mut b, "Total Row").changed() {+ if ui.checkbox(&mut b, tl!("Total Row")).changed() { run(app, "table.options", json!({"totalRow": b})); } let mut e = flags.4;- if ui.checkbox(&mut e, "Last Column").changed() {+ if ui.checkbox(&mut e, tl!("Last Column")).changed() { run(app, "table.options", json!({"lastColumn": e})); } }); ui.horizontal(|ui| { let mut c = flags.2;- if ui.checkbox(&mut c, "Banded Rows").changed() {+ if ui.checkbox(&mut c, tl!("Banded Rows")).changed() { run(app, "table.options", json!({"bandedRows": c})); } let mut f = flags.5;- if ui.checkbox(&mut f, "Banded Columns").changed() {+ if ui.checkbox(&mut f, tl!("Banded Columns")).changed() { run(app, "table.options", json!({"bandedColumns": f})); } });@@ -115,7 +115,7 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { menu_item(app, ui, Icon::Borders, label, "table.borders", p); } ui.separator();- ui.menu_button("Pen Weight", |ui| {+ ui.menu_button(tl!("Pen Weight"), |ui| { for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); if ui.radio(cur, format!("{w} pt")).clicked() {@@ -123,7 +123,7 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { } } });- ui.menu_button("Pen Style", |ui| {+ ui.menu_button(tl!("Pen Style"), |ui| { for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; if ui.radio(cur, l).clicked() {@@ -131,7 +131,7 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) { } } });- ui.menu_button("Pen Color", |ui| {+ ui.menu_button(tl!("Pen Color"), |ui| { let sc = scheme(app); if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { app.table_pen["color"] = cref_param(&c);@@ -221,7 +221,7 @@ pub(super) fn table_layout(app: &mut SlideApp, ui: &mut Ui) { run(app, "table.cellAlign", json!({"anchor": a})); } }- ui.menu_button("Cell Margins", |ui| {+ ui.menu_button(tl!("Cell Margins"), |ui| { for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { if ui.button(l).clicked() { run(app, "table.cellMargins", json!({"preset": preset}));@@ -68,7 +68,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { app.palette = Some((String::new(), 0)); } let share = right.add(- egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0)))+ egui::Button::new(egui::RichText::new(tl!(" Present ")).color(t.accent_text).font(theme::bold(13.0))) .fill(t.accent) .corner_radius(control_h / 2.0) .min_size(vec2(84.0, control_h)),@@ -69,7 +69,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { // Sound in its own column, so the timing column keeps its three rows. rows(ui, |ui| { ui.horizontal(|ui| {- let field = ui.label("Sound:");+ let field = ui.label(tl!("Sound:")); let stop = cur.as_ref().is_some_and(|t| t.stop_sound); let name = cur .as_ref()@@ -78,10 +78,10 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound);- if ui.selectable_label(none, "[No Sound]").clicked() {+ if ui.selectable_label(none, tl!("[No Sound]")).clicked() { run(app, "transition.sound", json!({"none": true})); }- if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() {+ if ui.selectable_label(stop, tl!("[Stop Previous Sound]")).clicked() { run(app, "transition.sound", json!({"stopPrevious": true})); } ui.separator();@@ -93,7 +93,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { } ui.separator(); let can_pick = app.services.pick_open.is_some();- let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…"));+ let r = ui.add_enabled(can_pick, egui::Button::new(tl!("Other Sound…"))); if r.clicked() { app.pick_transition_sound(); }@@ -110,7 +110,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); rows(ui, |ui| { ui.horizontal(|ui| {- ui.label("Duration:");+ ui.label(tl!("Duration:")); let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64}));@@ -118,14 +118,14 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click);- if ui.checkbox(&mut click, "On Mouse Click").changed() {+ if ui.checkbox(&mut click, tl!("On Mouse Click")).changed() { run(app, "transition.timing", json!({"onClick": click})); } }); ui.horizontal(|ui| { let after = cur.as_ref().and_then(|t| t.advance_after_ms); let mut on = after.is_some();- if ui.checkbox(&mut on, "After:").changed() {+ if ui.checkbox(&mut on, tl!("After:")).changed() { run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); } if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) {@@ -33,15 +33,15 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { let mut r = app.ui.ruler;- if ui.checkbox(&mut r, "Ruler").changed() {+ if ui.checkbox(&mut r, tl!("Ruler")).changed() { app.ui.ruler = r; } let mut g = app.ui.gridlines;- if ui.checkbox(&mut g, "Gridlines").changed() {+ if ui.checkbox(&mut g, tl!("Gridlines")).changed() { app.ui.gridlines = g; } let mut gd = app.ui.guides;- if ui.checkbox(&mut gd, "Guides").changed() {+ if ui.checkbox(&mut gd, tl!("Guides")).changed() { app.ui.guides = gd; } });@@ -55,7 +55,7 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false);- if main.on_hover_text("Zoom").clicked() {+ if main.on_hover_text(tl!("Zoom")).clicked() { let _ = app.run("view.zoomDialog", json!({})); } if let Some(a) = arrow {@@ -66,7 +66,7 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } }- if ui.button("Fit").clicked() {+ if ui.button(tl!("Fit")).clicked() { let _ = app.run("view.fit", json!({})); ui.close(); }@@ -80,7 +80,7 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) { let has = app.session.active().is_some(); let on = app.doc_window.is_some(); let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0;- if resp.on_hover_text("Open another window on this presentation").clicked() {+ if resp.on_hover_text(tl!("Open another window on this presentation")).clicked() { let _ = app.run("view.newWindow", json!({})); } });@@ -140,7 +140,7 @@ pub(super) fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { }); ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); ui.separator();- if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() {+ if ui.add_enabled(!picked.is_empty(), egui::Button::new(tl!("Insert"))).clicked() { let mut v = picked.clone(); v.sort_unstable(); let p = match kind {@@ -51,6 +51,10 @@ pub enum ViewMode { #[serde(default, rename_all = "camelCase")] pub struct UiState { pub brightness: theme::Brightness,+ /// Interface language setting: [`i18n::AUTO`] follows the system, otherwise a language code+ /// (`ja`, `en`). Menus, the ribbon, the status bar and the command labels are drawn in it;+ /// command ids, the presentation and file names are not.+ pub language: String, pub tab: String, pub ribbon_collapsed: bool, pub view: ViewMode,@@ -97,6 +101,7 @@ impl Default for UiState { fn default() -> Self { UiState { brightness: theme::Brightness::Light,+ language: i18n::AUTO.into(), tab: "Home".into(), ribbon_collapsed: false, view: ViewMode::Normal,@@ -151,6 +156,8 @@ impl UiState { if !short(&self.format_tab) { self.format_tab = d.format_tab; }+ // A hand-edited or newer `language` value falls back to following the system.+ self.language = crate::i18n::normalize_pref(&self.language).unwrap_or(crate::i18n::AUTO).to_string(); self.pane = self.pane.filter(short); self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); self.recent.dedup();@@ -17,8 +17,11 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let n = st.doc.slides.len(); let left = match st.selection.target {- deckcraft_engine::Target::Slides => format!("Slide {} of {}", (st.selection.slide + 1).min(n.max(1)), n),- _ => "Slide Master".to_string(),+ deckcraft_engine::Target::Slides => {+ let current = (st.selection.slide + 1).min(n.max(1)).to_string();+ crate::i18n::fmt(tl!("Slide {current} of {total}"), &[("current", ¤t), ("total", &n.to_string())])+ }+ _ => tl!("Slide Master").to_string(), }; let p = ui.painter(); // Left to right: slide counter, language, status message. Each starts at its usual column, or@@ -29,14 +32,14 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let right_x = rect.max.x - 560.0; // The selection's proofing language; a click opens Review › Language. let (lang, no_proof) = crate::dialogs::selected_language(app);- let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { " (not checked)" } else { "" };+ let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { tl!(" (not checked)") } else { "" }; let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0); let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y)); let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); let hit = lr.expand(3.0).intersect(lclip); if hit.is_positive() {- let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language");- resp.widget_info(|| named_with_value("Language", &name));+ let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text(tl!("Language"));+ resp.widget_info(|| named_with_value(tl!("Language"), &name)); if resp.clicked() { app.dialog = Some(crate::dialogs::Dialog::new("language")); }@@ -55,7 +58,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { .layout(egui::Layout::right_to_left(egui::Align::Center)), ); right.spacing_mut().item_spacing.x = 4.0;- if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() {+ if small_icon(&mut right, Icon::FitSmall, tl!("Fit slide to current window"), false).clicked() { app.ui.zoom = None; } let pct = (app.canvas_scale * 100.0).round();@@ -64,7 +67,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { if lbl.clicked() { let _ = app.run("view.zoomDialog", json!({})); }- if small_icon(&mut right, Icon::ZoomIn, "Zoom In", false).clicked() {+ if small_icon(&mut right, Icon::ZoomIn, tl!("Zoom In"), false).clicked() { let _ = app.run("view.zoomIn", json!({})); } // Slider 10%–400% (log scale).@@ -84,31 +87,31 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { z = zp / 100.0; app.ui.zoom = Some(z); }- if small_icon(&mut right, Icon::ZoomOut, "Zoom Out", false).clicked() {+ if small_icon(&mut right, Icon::ZoomOut, tl!("Zoom Out"), false).clicked() { let _ = app.run("view.zoomOut", json!({})); } right.add_space(8.0); let view = app.ui.view;- if view_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() {+ if view_icon(&mut right, Icon::ViewShowSmall, tl!("Slide Show"), false).clicked() { let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0); app.start_show(i, false); }- if view_icon(&mut right, Icon::ViewReadingSmall, "Reading View", view == ViewMode::Reading).clicked() {+ if view_icon(&mut right, Icon::ViewReadingSmall, tl!("Reading View"), view == ViewMode::Reading).clicked() { let _ = app.run("view.reading", json!({})); }- if view_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() {+ if view_icon(&mut right, Icon::ViewSorterSmall, tl!("Slide Sorter"), view == ViewMode::Sorter).clicked() { app.ui.view = ViewMode::Sorter; }- if view_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() {+ if view_icon(&mut right, Icon::ViewNormalSmall, tl!("Normal"), view == ViewMode::Normal).clicked() { app.ui.view = ViewMode::Normal; } right.add_space(8.0); let c_on = app.ui.pane.as_deref() == Some("comments");- if labeled(&mut right, Icon::CommentsSmall, "Comments", c_on).clicked() {+ if labeled(&mut right, Icon::CommentsSmall, tl!("Comments"), c_on).clicked() { let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true})); } let n_on = app.ui.notes;- if labeled(&mut right, Icon::NotesSmall, "Notes", n_on).clicked() {+ if labeled(&mut right, Icon::NotesSmall, tl!("Notes"), n_on).clicked() { app.ui.notes = !n_on; } let _ = Stroke::NONE;@@ -215,7 +218,7 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { .auto_shrink([false, false]) .show(ui, |ui| { crate::notes::editor(ui, &mut draft, &look, |e| {- e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)+ e.id(id).hint_text(tl!("Click to add notes")).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) }) }) .inner;@@ -49,6 +49,8 @@ fn big_button_width(text_w: f32, split: bool) -> f32 { /// response). Like Office's split buttons, the icon and the label text run the main command; only /// the chevron at the end of the label row opens the menu. pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option<Response>) {+ // The English label is the catalog key; a translation may break the two lines itself (`\n`).+ let label = crate::i18n::t(label); let t = Tokens::get(ui.ctx()); let font = theme::font(11.0); let lines: Vec<&str> = wrap_label(label);@@ -133,6 +135,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte /// Draw a small named button, optionally with a separately named options chevron, and return /// both click targets. pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option<Response>) {+ let label = crate::i18n::t(label); small_button_named(ui, icon, label, if label.is_empty() { "Options" } else { label }, enabled, selected, split) } @@ -244,6 +247,7 @@ fn split_popup_id(main: &Response) -> egui::Id { /// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the /// [`small_button`] items around it. pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response {+ let label = crate::i18n::t(label); let t = Tokens::get(ui.ctx()); let font = theme::font(12.0); let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0;@@ -262,6 +266,7 @@ pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response /// A square icon toggle (B, I, U, alignment…). pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response {+ let tip = crate::i18n::t(tip); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); let painter = ui.painter_at(rect);@@ -276,6 +281,7 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) /// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…). pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: bool) -> (Response, Response) {+ let tip = crate::i18n::t(tip); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover()); let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() });@@ -22,7 +22,7 @@ revision history. | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops media options and rewind (#74, #77) | File format | B | 2–4 | [file-format-parity.md](file-format-parity.md) | | 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 4–8 | [file-format-parity.md](file-format-parity.md) |-| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |+| 5 | UI English, first Japanese catalog in review: menus, ribbon, status bar and Preferences translated; dialogs, tooltips and help still English | Localization | B (5 languages) | 45–80 | [localization-parity.md](localization-parity.md) | | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) | | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | | 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) |@@ -335,9 +335,13 @@ Detail in [hardware-parity.md](hardware-parity.md). Detail in [localization-parity.md](localization-parity.md). -- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships- 26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the- infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).+- **A Japanese catalog is the first, and most of the UI is still English** (#15, #30; Ukrainian+ PR #34 open). The catalog system, system-language following and the Interface language setting+ landed with the Japanese catalog (554 entries: menus, ribbon, status bar, command labels and+ Preferences); dialog bodies, tooltips, help and the File-tab pages still show English. PowerPoint+ for Mac ships 26 languages. A new language is its catalog file plus one row in `LANGUAGES`+ (3–16 h plus native review). Beta needs at least Simplified Chinese, Spanish, French, German and+ Japanese (~30–45 h), with Japanese partly there. - **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face (Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.@@ -1,12 +1,18 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, Japanese interface, first step: a per-language TSV catalog, system-language following and the Interface language setting; the menus, ribbon, status bar, command labels and the Preferences dialog are drawn in Japanese, other dialog bodies still English; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (one proofing language for the whole presentation; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -DeckCraft's interface is English only today: strings are literals in the UI and engine code, with-no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34-(Ukrainian, open). Dimension score: **~8% ready** (estimated: English complete, slide-text script-support partly there), **55–90 h** to parity plus native-speaker review.+DeckCraft's interface is written in English, and a catalog per language+(`crates/ui-egui/src/i18n/*.tsv`) translates the strings the UI draws: English strings are the+keys, so a string without a translation shows in English and coverage grows one entry at a time.+The `language` setting follows the operating system by default; Preferences ▸ Interface language+picks English or 日本語 by hand. The Japanese catalog (554 entries) covers the menus, ribbon tabs+and buttons, status bar, command labels, command palette and the Preferences dialog; most dialog+bodies and the File-tab page content still show English. Requests: #15 (follow the system+language), #30 and PR #34 (Ukrainian, open). Dimension score: **~8% ready** (estimated: English+complete, slide-text script support partly there), **55–90 h** to parity plus native-speaker+review. ## How the numbers are measured @@ -33,21 +39,22 @@ support partly there), **55–90 h** to parity plus native-speaker review. | French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 | | Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 | | Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |-| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |+| Japanese | ja | 554 of ~1,050 (53%) | menus, ribbon, status bar, Preferences and command labels yes; other dialog bodies, tooltips and help no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | partial (in review) | yes | 4–7 | | German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 | | Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 | | Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 | -Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more+Other languages shipped: **0** (Japanese, the first catalog, is in review with this change; Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more than the twelve above: Czech, Danish, Greek, Finnish, Hebrew, Hungarian, Italian, Dutch, Norwegian, Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese. -Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a-sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows-the system (#15), plural and number formatting, and an `xtask` check that every key has a-translation. IME composition landed with #101 (slide text, table cells, notes and text fields;-one undo step per composition, underlined in place); the hours that included it are kept until-the next re-measure.+Per-language hours assume the infrastructure exists. That infrastructure is now in DeckCraft: the+sibling apps' TSV catalog format, system-language following, the Interface language setting and+catalog tests (format, duplicates, placeholders, well-formed bundled files), so a new language is+its catalog file plus one row in `LANGUAGES`. Remaining infrastructure work is plural and number+formatting and an `xtask` check that every key has a translation. IME composition landed with #101+(slide text, table cells, notes and text fields; one undo step per composition, underlined in+place); the hours that included it are kept until the next re-measure. ## Proofing languages @@ -62,6 +69,7 @@ of every text in the file and the default for new text (`format.languageAll`). | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. |+| 2026-10-21 | major | Japanese interface, first step: the TSV catalog system ported from the sibling apps, system-language following, the Interface language setting (auto \| English \| 日本語) in Preferences, and 554 Japanese strings covering the menus, ribbon, status bar, command labels, command palette and the Preferences dialog; other dialog bodies still English | | 2026-10-21 | minor | Review › Language can set the proofing language of the whole presentation | | 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret | | 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |@@ -1,6 +1,7 @@ # DeckCraft roadmap: milestones and what's next > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (beta item 9 drops Play From, now in; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (Japanese interface, first step: the string catalog exists, so the Ukrainian review item points at it; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in@@ -34,7 +35,7 @@ points and lead the beta plan. this repo, media options and rewind on save (#74, #77), relationship-bearing SmartArt and OLE (#203), EMF+ records and SVG. 31–55 h. 3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS- open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h.+ open-with (#68), font lookup (#80), Ukrainian (#34; the string catalog it needs now exists). 4–8 h. ## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents) @@ -116,6 +117,7 @@ plan above. | Date | Change | Summary | |---|---|---| | 2026-10-22 | trivial | Beta item 9: Animation Pane ▸ Play From landed, the item keeps effect options (3–6 h) |+| 2026-10-21 | minor | Japanese interface, first step: the string catalog landed, so the Ukrainian review item points at it. Milestones, scores and hours unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native save/reopen retains imported chart workbooks and related parts; headless command tests cover a PPTX → native → PPTX cycle and stale-workbook rejection. Milestone scope and scores unchanged | | 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged |