From 82dabedd068141e70cd94f42d84b861cfab67690 Mon Sep 17 00:00:00 2001 From: filforce95 <49290121+filforce95@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:11:12 +0100 Subject: [PATCH 01/11] i18n: interface languages from craft-i18n, English and German (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeckCraft reads the shared craft-i18n catalogs (roadmap step 3d there), wired as in WordCraft's pilot: strings stay English in the code and are looked up when drawn (`tl!`); `build.rs` embeds `locales//common.tsv` and `deckcraft.tsv` from `CRAFT_I18N_DIR` at the commit in `craft-i18n.rev`, and a build without it is English only. - `language` setting in ui.json: `auto` (follow the system, `DECKCRAFT_LANGUAGE` overrides) or a code; Preferences › Interface language and the `app.language` UI command set it, Cancel in Preferences restores it. Offered: English and German. - Translated: ribbon tabs and buttons (through the widgets), command palette (found by German or English name or id), status bar, File tab, dialog titles, buttons, fields and choice lists, panes. Command ids, tab ids, engine labels returned to scripts, the control channel, CLI and MCP stay English. - Tests: lookup and fallback, setting validation, `app.language`, switching the live UI to a German fixture and back (tab ids stay English), Preferences Cancel, and that embedded catalogs parse cleanly. - Docs: ATTRIBUTION (craft-i18n), localization-parity, gaps, ROADMAP. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + ATTRIBUTION.md | 10 + Cargo.lock | 2 + Cargo.toml | 6 + ROADMAP.md | 12 +- craft-i18n.rev | 1 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/build.rs | 84 ++++++- crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/app/commands.rs | 18 ++ crates/ui-egui/src/backstage.rs | 19 +- crates/ui-egui/src/backstage/files.rs | 20 +- crates/ui-egui/src/backstage/info.rs | 8 +- crates/ui-egui/src/backstage/print.rs | 20 +- crates/ui-egui/src/canvas/context_menu.rs | 50 ++-- crates/ui-egui/src/dialogs.rs | 16 +- crates/ui-egui/src/dialogs/about.rs | 10 +- crates/ui-egui/src/dialogs/body.rs | 175 +++++++------- crates/ui-egui/src/dialogs/credits.rs | 12 +- crates/ui-egui/src/dialogs/date_time.rs | 6 +- crates/ui-egui/src/dialogs/export.rs | 2 +- crates/ui-egui/src/dialogs/find.rs | 12 +- crates/ui-egui/src/dialogs/font.rs | 16 +- crates/ui-egui/src/dialogs/grid_options.rs | 4 +- crates/ui-egui/src/dialogs/paragraph.rs | 22 +- crates/ui-egui/src/dialogs/photo_album.rs | 28 +-- crates/ui-egui/src/dialogs/preferences.rs | 25 ++ crates/ui-egui/src/dialogs/size_position.rs | 6 +- crates/ui-egui/src/dialogs/tabs.rs | 14 +- crates/ui-egui/src/dialogs/theme_colors.rs | 4 +- crates/ui-egui/src/dialogs/theme_fonts.rs | 8 +- crates/ui-egui/src/dialogs/zoom.rs | 6 +- crates/ui-egui/src/doc_window.rs | 4 +- crates/ui-egui/src/i18n.rs | 241 ++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 148 ++++++++++++ crates/ui-egui/src/lib.rs | 10 + crates/ui-egui/src/menus.rs | 9 +- crates/ui-egui/src/opening.rs | 2 +- crates/ui-egui/src/panels/animation.rs | 8 +- crates/ui-egui/src/panels/background.rs | 18 +- crates/ui-egui/src/panels/comments.rs | 20 +- crates/ui-egui/src/panels/designer.rs | 6 +- crates/ui-egui/src/panels/fillui.rs | 20 +- crates/ui-egui/src/panels/format_shape.rs | 66 +++--- crates/ui-egui/src/panels/selpane.rs | 12 +- crates/ui-egui/src/panels/smartart.rs | 12 +- crates/ui-egui/src/panels/thesaurus.rs | 4 +- crates/ui-egui/src/panels/thumbs.rs | 22 +- crates/ui-egui/src/ribbon.rs | 21 +- crates/ui-egui/src/ribbon/animations.rs | 12 +- crates/ui-egui/src/ribbon/arrange.rs | 18 +- crates/ui-egui/src/ribbon/design.rs | 12 +- crates/ui-egui/src/ribbon/draw.rs | 6 +- crates/ui-egui/src/ribbon/galleries.rs | 4 +- crates/ui-egui/src/ribbon/home.rs | 50 ++-- crates/ui-egui/src/ribbon/insert.rs | 14 +- crates/ui-egui/src/ribbon/masters.rs | 8 +- crates/ui-egui/src/ribbon/picture_format.rs | 20 +- crates/ui-egui/src/ribbon/playback.rs | 12 +- crates/ui-egui/src/ribbon/qat.rs | 8 +- crates/ui-egui/src/ribbon/review.rs | 2 +- crates/ui-egui/src/ribbon/shape_format.rs | 20 +- crates/ui-egui/src/ribbon/slide_show.rs | 10 +- crates/ui-egui/src/ribbon/smartart.rs | 10 +- crates/ui-egui/src/ribbon/table.rs | 12 +- crates/ui-egui/src/ribbon/title_bar.rs | 4 +- crates/ui-egui/src/ribbon/transitions.rs | 12 +- crates/ui-egui/src/ribbon/view.rs | 14 +- crates/ui-egui/src/show/presenter.rs | 8 +- crates/ui-egui/src/show/ui.rs | 6 +- crates/ui-egui/src/sorter.rs | 18 +- crates/ui-egui/src/state.rs | 5 + crates/ui-egui/src/status.rs | 33 ++- crates/ui-egui/src/toast.rs | 2 +- crates/ui-egui/src/widgets.rs | 42 ++-- docs/gaps.md | 12 +- docs/localization-parity.md | 30 ++- 77 files changed, 1125 insertions(+), 523 deletions(-) create mode 100644 .gitattributes create mode 100644 craft-i18n.rev create mode 100644 crates/ui-egui/src/i18n.rs create mode 100644 crates/ui-egui/src/i18n/tests.rs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..e9ad0f2b16 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +craft-i18n.rev text eol=lf diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ff680bda22..f2d4976bb7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -68,6 +68,16 @@ families excluded by `crates/fonts/build.rs`: Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest provides one) and BIZ UDPGothic Regular. +## Embedded at build time from craft-i18n (`CRAFT_I18N_DIR`) + +Builds with a craft-i18n checkout (at the commit in `craft-i18n.rev`) embed its interface +catalogs for the languages DeckCraft offers (`locales//common.tsv` and `deckcraft.tsv`). +They are text, not files in this repository; without the checkout the interface is English only. + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| craft-i18n interface catalogs | craft-i18n translators, credited in its [ATTRIBUTION.md](https://github.com/storytold/craft-i18n/blob/main/ATTRIBUTION.md) | https://github.com/storytold/craft-i18n at `craft-i18n.rev` | MIT OR Apache-2.0 | Interface languages other than English (German first); clean-room translations from the English interface labels | + ## PDF writer colour profiles `craft-pdf` is used with only its `write` feature and default features disabled. Krilla diff --git a/Cargo.lock b/Cargo.lock index 01a92e4a50..ecf46e73fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1262,6 +1262,7 @@ dependencies = [ name = "deckcraft-ui-egui" version = "0.11.1" dependencies = [ + "craft-i18n", "craft-ui", "craft-update", "deckcraft-anim", @@ -1279,6 +1280,7 @@ dependencies = [ "serde", "serde_json", "skrifa 0.47.0", + "sys-locale", "toml", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index 8d46cd437e..06c9e05937 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,12 @@ craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cf craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +# Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n +# catalogs that `crates/ui-egui/build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, +# pinned in `craft-i18n.rev`). Same craft-libs rev as craft-ui. +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } +# The system's preferred interface languages (#15). Native: OS APIs; wasm: the browser's (`js`). +sys-locale = { version = "0.3", features = ["js"] } i_overlay = "9" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69f8..801a170727 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: 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-28 · **Change:** minor (interface languages from craft-i18n: language setting and German, #15; localization ~8% → ~15%; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **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) @@ -55,7 +55,7 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b | UI/UX fidelity | ~60% | 30–50 | [docs/ui-parity.md](docs/ui-parity.md) | | File formats (PPTX, PDF, legacy, export) | ~58% | 60–110 | [docs/file-format-parity.md](docs/file-format-parity.md) | | Hardware (GPU raster, video decode, displays, pens) | ~40% | 25–45 | [docs/hardware-parity.md](docs/hardware-parity.md) | -| Localization | ~8% | 55–90 + native review | [docs/localization-parity.md](docs/localization-parity.md) | +| Localization | ~15% | 45–80 + native review | [docs/localization-parity.md](docs/localization-parity.md) | | Performance (unmeasured) | ~55% | 10–20 | [docs/hardware-parity.md](docs/hardware-parity.md#performance) | | Stability | ~60% | 10–20 | [docs/gaps.md](docs/gaps.md#stability) | | Platforms | ~80% | 5–10 | [Downloads in README](README.md#downloads) | @@ -90,7 +90,7 @@ 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: +English and German. Interface text comes from the shared [craft-i18n](https://github.com/storytold/craft-i18n) catalogs (`locales//deckcraft.tsv`); the interface follows the system language, or Preferences › Interface language (#15). Detail: [docs/localization-parity.md](docs/localization-parity.md). | Language | Code | UI strings | Status | Hours to full | @@ -104,11 +104,11 @@ English only today; no string catalog yet (Ukrainian is in review as PR #34). De | Portuguese | pt | 0% | none | 3–5 | | Indonesian | id | 0% | none | 3–5 | | Japanese | ja | 0% | none | 4–7 | -| German | de | 0% | none | 3–5 | +| German | de | ~80% | partial | 1–3 | | 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 +The catalog infrastructure is in place: a new language is a `deckcraft.tsv` in craft-i18n plus one entry in `LANGUAGES` (`crates/ui-egui/src/i18n.rs`). PowerPoint for Mac ships 26. ## Upcoming @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-28 | Interface languages (#15): DeckCraft reads the craft-i18n catalogs (`CRAFT_I18N_DIR`, pinned in `craft-i18n.rev`), follows the system language and has Preferences › Interface language and `app.language`. German is the first language: ribbon, menus, command palette, status bar, File tab, dialogs and panes | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | @@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Interface languages from craft-i18n with German (#15): Localization ~8% → ~15%, German row partial | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | diff --git a/craft-i18n.rev b/craft-i18n.rev new file mode 100644 index 0000000000..9ff31dd75b --- /dev/null +++ b/craft-i18n.rev @@ -0,0 +1 @@ +19358491340b9f33d618673de6bcd40108d9a775 diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 88b9f770b7..c84162e083 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,8 @@ deckcraft-engine = { workspace = true } deckcraft-media = { workspace = true } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } craft-ui = { workspace = true } +craft-i18n = { workspace = true } +sys-locale = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007fb2f..65cf94cdbb 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,5 +1,6 @@ //! Bake the contributor credits into the binary as static Rust tables (craftrules -//! standards/contributors.md). Nothing is read at run time. +//! standards/contributors.md), and embed the craft-i18n interface catalogs. Nothing is read at +//! run time. //! //! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules //! scripts/contributors.py, which also marks whose people.toml entry it verified. @@ -7,6 +8,12 @@ //! JSON says the entry was verified (`people_entry`). //! //! A missing or malformed file gives empty tables (or no names) and a warning. +//! +//! - `CRAFT_I18N_DIR`, a craft-i18n checkout (storytold/craft-i18n, at the SHA in +//! `craft-i18n.rev`): `craft_i18n.rs` holds `CRAFT_I18N`, one (locale, shared catalog, DeckCraft +//! catalog) entry per locale that has a `deckcraft.tsv`. Unset, or not a checkout (a warning), +//! it is empty and the interface is English only; with `CRAFT_I18N_REQUIRED=1` (releases) that +//! is an error instead. Recipe: craft-i18n's `docs/integration.md`. use std::fmt::Write as _; @@ -24,6 +31,81 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } + let out = out.with_file_name("craft_i18n.rs"); + if let Err(e) = std::fs::write(&out, craft_i18n()) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// The catalog this app reads in each locale: `locales//deckcraft.tsv`. +const APP_CATALOG: &str = "deckcraft.tsv"; + +/// `CRAFT_I18N` from `CRAFT_I18N_DIR`: the `Embedded` list the craft-i18n crate reads. +fn craft_i18n() -> String { + println!("cargo::rerun-if-env-changed=CRAFT_I18N_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_I18N_REQUIRED"); + let required = std::env::var("CRAFT_I18N_REQUIRED").is_ok_and(|v| v.trim() == "1"); + let entries = match std::env::var_os("CRAFT_I18N_DIR").filter(|d| !d.is_empty()) { + // Not canonicalized: on Windows that gives `\\?\C:\…`, which `include_str!` can't read. + Some(dir) => match locales(&std::path::PathBuf::from(dir).join("locales")) { + Ok(entries) => entries, + Err(e) => { + let level = if required { "error" } else { "warning" }; + println!("cargo::{level}=CRAFT_I18N_DIR: {e}; the interface is English only"); + Vec::new() + } + }, + None => { + if required { + println!("cargo::error=CRAFT_I18N_REQUIRED=1 but CRAFT_I18N_DIR is unset: a release must embed the translations"); + } + Vec::new() + } + }; + let mut o = String::from("/// (locale, shared catalog, DeckCraft's catalog), one entry per locale; `\"\"` for a missing file.\n"); + o.push_str("pub static CRAFT_I18N: &craft_i18n::Embedded = &[\n"); + for (code, shared, app) in entries { + // `{:?}` escapes the path (Windows backslashes) as a Rust string literal. + let shared = shared.map_or_else(|| "\"\"".to_string(), |p| format!("include_str!({p:?})")); + let _ = writeln!(o, " ({code:?}, {shared}, include_str!({app:?})),"); + } + o.push_str("];\n"); + o +} + +/// A locale's code, its `common.tsv` if any, and its app catalog. +type Locale = (String, Option, std::path::PathBuf); + +/// Every locale under `dir` with this app's catalog, by code: (code, its `common.tsv` if any, its +/// app catalog). An error when `dir` isn't a craft-i18n `locales/` directory. +fn locales(dir: &std::path::Path) -> Result, String> { + // Build scripts run in the crate's directory and `include_str!` resolves against `OUT_DIR`, so + // a relative path would mean two different places. + if !dir.is_absolute() { + return Err(format!("{} is not an absolute path", dir.display())); + } + let list = std::fs::read_dir(dir).map_err(|e| format!("{} is not a craft-i18n checkout ({e})", dir.display()))?; + println!("cargo::rerun-if-changed={}", dir.display()); + let mut out = Vec::new(); + for entry in list.flatten() { + let path = entry.path(); + let Some(code) = path.file_name().and_then(std::ffi::OsStr::to_str).map(str::to_string) else { continue }; + let app = path.join(APP_CATALOG); + if !app.is_file() { + continue; + } + println!("cargo::rerun-if-changed={}", app.display()); + let shared = Some(path.join("common.tsv")).filter(|p| p.is_file()); + if let Some(p) = &shared { + println!("cargo::rerun-if-changed={}", p.display()); + } + out.push((code, shared, app)); + } + if out.is_empty() { + return Err(format!("{} has no locale with a {APP_CATALOG}", dir.display())); + } + out.sort(); + Ok(out) } fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8496d0ed9e..cf0b79634a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -283,6 +283,8 @@ impl SlideApp { self.requeue_injected_synthetic(); return; } + // The language every label this pass draws is looked up in (Preferences › Language). + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.ui.language)); let t0 = now_ms(); let t = theme::Tokens::get(&ctx); self.note_ui_text(&ctx); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 216dd30d3e..b2998d918e 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,6 +1,7 @@ #[cfg(not(target_arch = "wasm32"))] use super::updates_ui; use super::{SlideApp, UI_COMMANDS, Value, ViewMode, backstage, dialogs, doc_window, json, media, qat, theme}; +use crate::i18n; fn grid_options_touched(p: &Value) -> bool { ["spacing", "snap", "smartGuides", "gridlines", "guides"].iter().any(|k| p.get(*k).is_some()) @@ -38,6 +39,13 @@ impl SlideApp { r } + /// The `language` setting, the language the interface is drawn in, and every one it can be + /// (`app.language`). + fn language_state(&self) -> Value { + let available: Vec = i18n::Lang::all().map(|l| json!({"code": l.code(), "name": l.name()})).collect(); + json!({"language": self.ui.language, "current": i18n::Lang::from_pref(&self.ui.language).code(), "available": available}) + } + fn flag(&mut self, params: &Value, cur: bool) -> bool { params.get("on").and_then(Value::as_bool).unwrap_or(!cur) } @@ -219,6 +227,16 @@ impl SlideApp { } "app.palette" => self.palette = Some((String::new(), 0)), "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), + "app.language" => { + if let Some(v) = p.get("language") { + let code = v.as_str().and_then(i18n::normalize_pref).ok_or_else(|| { + let offered: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect(); + format!("`language` must be one of {}", offered.join(", ")) + })?; + self.ui.language = code.to_string(); + } + return Ok(self.language_state()); + } "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), "app.smartArtPictureDialog" => { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 8d91d121cb..3e5e1d8a55 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -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); @@ -340,7 +340,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { t.text, false, ); - if resp.on_hover_text("Back (Esc)").clicked() { + if resp.on_hover_text(tl!("Back (Esc)")).clicked() { out.close = true; } } else { @@ -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,31 +460,32 @@ fn separator(ui: &mut Ui, t: &Tokens) { fn heading(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(text).font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(tl!(text)).font(theme::bold(16.0)).color(t.text)); ui.add_space(8.0); } fn dim(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(text).font(theme::font(12.5)).color(t.text_dim)); + ui.label(RichText::new(tl!(text)).font(theme::font(12.5)).color(t.text_dim)); } /// A link with an arrow (More themes →): true when clicked. fn arrow_link(ui: &mut Ui, text: &str) -> bool { let t = Tokens::get(ui.ctx()); - ui.add(egui::Label::new(RichText::new(format!("{text} →")).font(theme::font(12.5)).color(t.accent)).sense(Sense::click())) + ui.add(egui::Label::new(RichText::new(format!("{} →", tl!(text))).font(theme::font(12.5)).color(t.accent)).sense(Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand) .clicked() } /// A search box; true when its text changed. fn search(ui: &mut Ui, query: &mut String, hint: &str, width: f32) -> bool { - ui.add(egui::TextEdit::singleline(query).hint_text(hint).desired_width(width)).changed() + ui.add(egui::TextEdit::singleline(query).hint_text(tl!(hint)).desired_width(width)).changed() } /// Draw a labelled icon button and return whether it was clicked while enabled. fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); + let label = tl!(label); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); if resp.hovered() && enabled { @@ -499,7 +500,7 @@ fn bullets(ui: &mut Ui, items: &[&str]) { for b in items { ui.horizontal(|ui| { ui.add_space(6.0); - ui.label(RichText::new(format!("• {b}")).font(theme::font(12.5))); + ui.label(RichText::new(format!("• {}", tl!(b))).font(theme::font(12.5))); }); } ui.add_space(10.0); diff --git a/crates/ui-egui/src/backstage/files.rs b/crates/ui-egui/src/backstage/files.rs index 4b7cec6661..dafa513d2e 100644 --- a/crates/ui-egui/src/backstage/files.rs +++ b/crates/ui-egui/src/backstage/files.rs @@ -41,7 +41,7 @@ fn new_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize, blank: } ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(10.0, 10.0); - if blank && tile(ui, "Blank Presentation", None, false).clicked() { + if blank && tile(ui, tl!("Blank Presentation"), None, false).clicked() { let _ = app.run("file.new", json!({})); } let mut shown = 0; @@ -143,7 +143,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); - if x.on_hover_text("Remove from Recent").clicked() { + if x.on_hover_text(tl!("Remove from Recent")).clicked() { app.forget_recent(&path); } if resp.clicked() { @@ -210,11 +210,11 @@ pub(super) fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mu ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); crate::dialogs::paint_logo(ui.painter(), r); - ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); + ui.label(RichText::new(tl!("DeckCraft")).font(theme::bold(24.0)).color(t.text)); }); ui.add_space(18.0); ui.horizontal(|ui| { - ui.label(RichText::new("New").font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(tl!("New")).font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if arrow_link(ui, "More themes") { out.page = Some(Page::New); @@ -225,7 +225,7 @@ pub(super) fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mu new_tiles(app, ui, "", 5, true); ui.add_space(22.0); ui.horizontal(|ui| { - ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(tl!("Templates")).font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if arrow_link(ui, "More templates") { out.page = Some(Page::New); @@ -236,7 +236,7 @@ pub(super) fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mu template_tiles(app, ui, "", 3); ui.add_space(22.0); ui.horizontal(|ui| { - ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(tl!("Recent")).font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { search(ui, &mut bs.query, "Search for a file", 280.0); }); @@ -252,13 +252,13 @@ pub(super) fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mu }); } ui.add_space(30.0); - ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to(tl!("Join the ArtCraft community on Discord"), deckcraft_engine::links::DISCORD); } /// New: a blank presentation, then every theme and starter template, with a search box. pub(super) fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { let t = Tokens::get(ui.ctx()); - if tile(ui, "Blank Presentation", None, true).clicked() { + if tile(ui, tl!("Blank Presentation"), None, true).clicked() { let _ = app.run("file.new", json!({})); } ui.add_space(8.0); @@ -289,12 +289,12 @@ pub(super) fn open_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.vertical(|ui| { ui.set_width(230.0); place_row(ui, Icon::Recent, "Recent", true, 220.0); - if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text("Open a presentation from a folder (Ctrl+O)").clicked() { + if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text(tl!("Open a presentation from a folder (Ctrl+O)")).clicked() { let _ = app.run("app.openDialog", json!({})); } if app.session.recovery_dir.is_some() { ui.add_space(24.0); - if ui.button("Recover Unsaved Presentations").clicked() { + if ui.button(tl!("Recover Unsaved Presentations")).clicked() { match app.run("file.recovery.open", json!({})) { Ok(v) if v.get("opened").and_then(Value::as_array).is_some_and(|a| a.is_empty()) => { app.set_status("There are no unsaved presentations to recover"); diff --git a/crates/ui-egui/src/backstage/info.rs b/crates/ui-egui/src/backstage/info.rs index 690e55245c..8c465cea85 100644 --- a/crates/ui-egui/src/backstage/info.rs +++ b/crates/ui-egui/src/backstage/info.rs @@ -42,13 +42,13 @@ pub(super) fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { if let Some(p) = &path { ui.add_space(8.0); ui.horizontal(|ui| { - if ui.button("Copy path").clicked() { + if ui.button(tl!("Copy path")).clicked() { ui.ctx().copy_text(p.clone()); app.set_status("Copied the path"); } #[cfg(not(target_arch = "wasm32"))] if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str) - && ui.button("Open file location").clicked() + && ui.button(tl!("Open file location")).clicked() { app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/'))); } @@ -220,7 +220,7 @@ pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) { ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); crate::dialogs::paint_logo(ui.painter(), r); - ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text)); + ui.label(RichText::new(tl!("DeckCraft")).font(theme::bold(24.0)).color(t.text)); }); dim(ui, &crate::dialogs::version_line()); dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft."); @@ -235,7 +235,7 @@ pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) { ) { let _ = app.run("app.about", json!({})); } - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to(tl!("Join our community on Discord"), deckcraft_engine::links::DISCORD); ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); }); }); diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs index 612ecaf753..a323ed7411 100644 --- a/crates/ui-egui/src/backstage/print.rs +++ b/crates/ui-egui/src/backstage/print.rs @@ -109,8 +109,8 @@ pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); ui.add_space(4.0); ui.horizontal(|ui| { - let l = ui.label("Slides:"); - let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text("e.g. 1-3, 5").desired_width(176.0)).labelled_by(l.id); + let l = ui.label(tl!("Slides:")); + let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text(tl!("e.g. 1-3, 5")).desired_width(176.0)).labelled_by(l.id); if r.changed() { // Typing a range picks Custom Range, as in PowerPoint. bs.print_scope = if bs.print_range.trim().is_empty() { PrintScope::All } else { PrintScope::Custom }; @@ -161,10 +161,10 @@ pub(super) fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); }); ui.add_space(4.0); - if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { + if ui.checkbox(&mut bs.print_hidden, tl!("Print Hidden Slides")).changed() { bs.preview_page = 0; } - ui.checkbox(&mut bs.print_frame, "Frame Slides"); + ui.checkbox(&mut bs.print_frame, tl!("Frame Slides")); ui.add_space(6.0); if ui.link("Edit Header & Footer").clicked() { let _ = app.run("app.dialog", json!({"id": "headerFooter"})); @@ -306,19 +306,19 @@ pub(super) fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.set_max_width(560.0); match bs.export { ExportTask::Pdf => { - ui.label(RichText::new("Create a PDF Document").font(theme::font(16.0)).color(t.text)); + ui.label(RichText::new(tl!("Create a PDF Document")).font(theme::font(16.0)).color(t.text)); bullets( ui, &["Keeps the layout, fonts and pictures", "Opens on any device with a PDF viewer", "The presentation keeps its own file"], ); - ui.checkbox(&mut bs.pdf_hidden, "Include hidden slides"); + ui.checkbox(&mut bs.pdf_hidden, tl!("Include hidden slides")); ui.add_space(10.0); if square_button(ui, Icon::File, "Create PDF", true).clicked() { app.export_as("pdf", json!({"includeHidden": bs.pdf_hidden})); } } ExportTask::Handouts => { - ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); + ui.label(RichText::new(tl!("Create Handouts")).font(theme::font(16.0)).color(t.text)); bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); let combo = craft_ui::ComboBox::from_id_salt("backstage_handouts") .width(240.0) @@ -337,7 +337,7 @@ pub(super) fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } ExportTask::FileType => { - ui.label(RichText::new("Change File Type").font(theme::font(16.0)).color(t.text)); + ui.label(RichText::new(tl!("Change File Type")).font(theme::font(16.0)).color(t.text)); ui.add_space(8.0); for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { let selected = bs.file_type == *id; @@ -358,8 +358,8 @@ pub(super) fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.add_space(12.0); let png = bs.file_type == "png"; if png { - ui.checkbox(&mut bs.png_transparent, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); + ui.checkbox(&mut bs.png_transparent, tl!("Transparent background")) + .on_hover_text(tl!("Leave the slide background out: only what is on the slide is drawn")); ui.add_space(8.0); } if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { diff --git a/crates/ui-egui/src/canvas/context_menu.rs b/crates/ui-egui/src/canvas/context_menu.rs index 1aa1c92860..e41c05317b 100644 --- a/crates/ui-egui/src/canvas/context_menu.rs +++ b/crates/ui-egui/src/canvas/context_menu.rs @@ -47,14 +47,14 @@ fn edit_points_menu(app: &mut SlideApp, ui: &mut egui::Ui, sh: &deckcraft_model: } } // Each of these renumbers the points, so the picked segment is dropped. - if ui.button("Add Point").clicked() { + if ui.button(tl!("Add Point")).clicked() { let t = app.session.tool.edit_segment_t.unwrap_or(0.5); let _ = app.run("shape.addPoint", json!({"path": path, "segment": index, "t": t})); app.session.tool.edit_segment = None; ui.close(); } let closed = deckcraft_engine::cmd::points::closed_at(sh, path, index); - if ui.add_enabled(closed == Some(true), egui::Button::new("Delete Segment")).clicked() { + if ui.add_enabled(closed == Some(true), egui::Button::new(tl!("Delete Segment"))).clicked() { let _ = app.run("shape.deleteSegment", json!({"path": path, "segment": index})); app.session.tool.edit_segment = None; ui.close(); @@ -78,7 +78,7 @@ fn edit_points_menu(app: &mut SlideApp, ui: &mut egui::Ui, sh: &deckcraft_model: ui.close(); } } - if ui.button("Delete Point").clicked() { + if ui.button(tl!("Delete Point")).clicked() { let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); app.session.tool.edit_vertex = None; ui.close(); @@ -95,7 +95,7 @@ fn edit_points_menu(app: &mut SlideApp, ui: &mut egui::Ui, sh: &deckcraft_model: } ui.separator(); } - if ui.button("Exit Edit Points").clicked() { + if ui.button(tl!("Exit Edit Points")).clicked() { let _ = app.run("shape.editPoints", json!({"on": false})); ui.close(); } @@ -103,7 +103,7 @@ fn edit_points_menu(app: &mut SlideApp, ui: &mut egui::Ui, sh: &deckcraft_model: fn spelling_menu(app: &mut SlideApp, ui: &mut egui::Ui, w: &crate::squiggles::MenuWord) { if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + if ui.button(egui::RichText::new(tl!("Delete Repeated Word")).strong()).clicked() { let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); // The gap is spaces; take it from the text so tabs or no-break spaces match. let gap = app @@ -140,7 +140,7 @@ fn spelling_menu(app: &mut SlideApp, ui: &mut egui::Ui, w: &crate::squiggles::Me ui.close(); } } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + ui.label(egui::RichText::new(tl!("(no spelling suggestions)")).weak()); } for to in &w.suggestions { if ui.button(egui::RichText::new(to).strong()).clicked() { @@ -156,12 +156,12 @@ fn spelling_menu(app: &mut SlideApp, ui: &mut egui::Ui, w: &crate::squiggles::Me ui.close(); } } - if ui.button("Ignore All").clicked() { + if ui.button(tl!("Ignore All")).clicked() { app.squiggles.ignore(&w.word); app.squiggles.menu_word = None; ui.close(); } - if ui.button("Add to Dictionary").clicked() { + if ui.button(tl!("Add to Dictionary")).clicked() { if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { app.set_status(e.to_string()); } @@ -172,7 +172,7 @@ fn spelling_menu(app: &mut SlideApp, ui: &mut egui::Ui, w: &crate::squiggles::Me } fn text_items(app: &mut SlideApp, ui: &mut egui::Ui) { - if ui.button("Paragraph…").clicked() { + if ui.button(tl!("Paragraph…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); ui.close(); } @@ -182,34 +182,34 @@ fn text_items(app: &mut SlideApp, ui: &mut egui::Ui) { ui.close(); } } - if ui.button("Link…").clicked() { + if ui.button(tl!("Link…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); } ui.separator(); - if ui.button("Format Shape…").clicked() { + if ui.button(tl!("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format"})); ui.close(); } } fn shape_items(app: &mut SlideApp, ui: &mut egui::Ui) { - if ui.button("Edit Text").clicked() { + if ui.button(tl!("Edit Text")).clicked() { let _ = app.run("text.edit", json!({})); ui.close(); } if crate::ribbon::enabled(app, "shape.editPoints") && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) - && ui.button("Edit Points").clicked() + && ui.button(tl!("Edit Points")).clicked() { let _ = app.run("shape.editPoints", json!({"on": true})); ui.close(); } - if ui.button("Font…").clicked() { + if ui.button(tl!("Font…")).clicked() { let _ = app.run("format.fontDialog", json!({})); ui.close(); } - if ui.button("Paragraph…").clicked() { + if ui.button(tl!("Paragraph…")).clicked() { let _ = app.run("format.paragraphDialog", json!({})); ui.close(); } @@ -232,27 +232,27 @@ fn shape_items(app: &mut SlideApp, ui: &mut egui::Ui) { } let picture = app.session.active().is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); - if picture && ui.button("Change Picture…").clicked() { + if picture && ui.button(tl!("Change Picture…")).clicked() { let _ = app.run("app.changePictureDialog", json!({})); ui.close(); } - if ui.button("Link…").clicked() { + if ui.button(tl!("Link…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); } - if ui.button("Edit Alt Text…").clicked() { + if ui.button(tl!("Edit Alt Text…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("altText")); ui.close(); } - if ui.button("Set as Default Shape").clicked() { + if ui.button(tl!("Set as Default Shape")).clicked() { let _ = app.run("shape.setDefault", json!({})); ui.close(); } - if ui.button("Size and Position…").clicked() { + if ui.button(tl!("Size and Position…")).clicked() { let _ = app.run("shape.sizePosition", json!({})); ui.close(); } - if ui.button("Format Shape…").clicked() { + if ui.button(tl!("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format"})); ui.close(); } @@ -274,7 +274,7 @@ fn slide_items(app: &mut SlideApp, ui: &mut egui::Ui) { }); ui.menu_button("Grid and Guides", |ui| { let mut on = app.ui.guides; - if ui.checkbox(&mut on, "Guides").clicked() { + if ui.checkbox(&mut on, tl!("Guides")).clicked() { let _ = app.run("view.guides", json!({"on": on})); } for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { @@ -285,15 +285,15 @@ fn slide_items(app: &mut SlideApp, ui: &mut egui::Ui) { } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(tl!("Reset Slide")).clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); } - if ui.button("Format Background…").clicked() { + if ui.button(tl!("Format Background…")).clicked() { let _ = app.run("view.pane", json!({"pane": "background"})); ui.close(); } - if ui.button("New Comment").clicked() { + if ui.button(tl!("New Comment")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("comment")); ui.close(); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ed33b9cd68..e2ab3117de 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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) @@ -132,12 +132,12 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new(egui::RichText::new(ok_label).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))).clicked() + if ui.add(egui::Button::new(egui::RichText::new(tl!(ok_label)).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { 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; } }); @@ -149,7 +149,7 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) { fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { let mut v = d.get(key, default); ui.horizontal(|ui| { - let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label)); + let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(tl!(label))); ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)).labelled_by(l.id); }); d.fields.insert(key.into(), v); @@ -157,7 +157,7 @@ fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { fn check(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: bool) -> bool { let mut v = d.get(key, if default { "1" } else { "0" }) == "1"; - ui.checkbox(&mut v, label); + ui.checkbox(&mut v, tl!(label)); d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); v } @@ -261,15 +261,15 @@ fn color_label(v: &Value) -> String { /// the caller supplies the visible label. fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choices: &[(&str, &str)]) { let cur = d.get(key, ""); - let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| tl!(l)).unwrap_or(cur.as_str()); let combo = craft_ui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { for (v, l) in choices { - if ui.selectable_label(cur == *v, *l).clicked() { + if ui.selectable_label(cur == *v, tl!(l)).clicked() { d.fields.insert((*key).into(), (*v).into()); } } }); - combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, name)); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, tl!(name))); } fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index 03c6629b21..3cd5d5fee8 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -20,17 +20,17 @@ pub fn about(_app: &mut SlideApp, ui: &mut Ui) { ui.vertical_centered(|ui| { let (r, _) = ui.allocate_exact_size(vec2(64.0, 64.0), Sense::hover()); paint_logo(ui.painter(), r); - ui.label(egui::RichText::new("DeckCraft").font(theme::bold(22.0))); + ui.label(egui::RichText::new(tl!("DeckCraft")).font(theme::bold(22.0))); ui.label(egui::RichText::new(version_line()).color(t.text_dim)); ui.add_space(6.0); - ui.label("Presentations and slide shows, rebuilt from scratch in pure Rust."); - ui.label("An open-source, clean-room project. Not affiliated with Microsoft."); + ui.label(tl!("Presentations and slide shows, rebuilt from scratch in pure Rust.")); + ui.label(tl!("An open-source, clean-room project. Not affiliated with Microsoft.")); ui.add_space(6.0); - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to(tl!("Join our community on Discord"), deckcraft_engine::links::DISCORD); ui.hyperlink_to("getartcraft.com/apps/deckcraft", deckcraft_engine::links::APP_PAGE); ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); ui.add_space(6.0); - ui.label(egui::RichText::new("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors").size(11.0).color(t.text_faint)); + ui.label(egui::RichText::new(tl!("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors")).size(11.0).color(t.text_faint)); }); } diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs index d458c2632f..7deb81ac58 100644 --- a/crates/ui-egui/src/dialogs/body.rs +++ b/crates/ui-egui/src/dialogs/body.rs @@ -11,7 +11,7 @@ use super::hyperlink::{hyperlink_command, hyperlink_seed_of, hyperlink_should_ap use super::language::selected_language; use super::paragraph::paragraph_dialog; use super::photo_album::photo_album_dialog; -use super::preferences::{preferences_snapshot, restore_preferences}; +use super::preferences::{language_row, preferences_snapshot, restore_preferences}; use super::size_position::size_position_dialog; use super::slide_size::{put_slide_dim, slide_dim_points, unit_abbr}; use super::tabs::tabs_dialog; @@ -99,7 +99,7 @@ fn dlg_slide_size(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { put_slide_dim(d, "w", cur.width, ppu); put_slide_dim(d, "h", cur.height, ppu); } - let label = ui.label("Slides sized for:"); + let label = ui.label(tl!("Slides sized for:")); let preset = d.get("preset", ""); let combo = craft_ui::ComboBox::from_id_salt("sizes").selected_text(if preset.is_empty() { "Custom" } else { &preset }).show_ui(ui, |ui| { for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { @@ -117,7 +117,7 @@ fn dlg_slide_size(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); ui.horizontal(|ui| { - ui.label("Orientation:"); + ui.label(tl!("Orientation:")); let portrait = ui.radio(h > w, "Portrait").clicked(); let landscape = ui.radio(w >= h, "Landscape").clicked(); // Picking the other orientation swaps the width and height fields. @@ -148,7 +148,7 @@ fn dlg_header_footer(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let label = rows.iter().find(|(k, _)| *k == fmt).map(|(_, s)| s.clone()).unwrap_or_else(|| fmt.clone()); ui.add_enabled_ui(date && automatic, |ui| { ui.horizontal(|ui| { - ui.label(" Format:"); + ui.label(tl!(" Format:")); craft_ui::ComboBox::from_id_salt("hf-date-format").selected_text(label).width(200.0).show_ui(ui, |ui| { for (kind, sample) in &rows { if ui.selectable_label(fmt == *kind, sample).clicked() { @@ -166,9 +166,9 @@ fn dlg_header_footer(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut apply_all = false; let mut apply = false; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - apply_all = ui.button("Apply to All").clicked(); - apply = ui.button("Apply").clicked(); - if ui.button("Cancel").clicked() { + apply_all = ui.button(tl!("Apply to All")).clicked(); + apply = ui.button(tl!("Apply")).clicked(); + if ui.button(tl!("Cancel")).clicked() { d.fields.insert("cancel".into(), "1".into()); } }); @@ -245,7 +245,7 @@ fn dlg_hyperlink(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { fn dlg_setup_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let sh = app.session.active().map(|s| s.doc.show.clone()).unwrap_or_default(); let ty = d.get("type", &sh.show_type); - ui.label("Show type"); + ui.label(tl!("Show type")); for (l, v) in [ ("Presented by a speaker (full screen)", "speaker"), ("Browsed by an individual (window)", "browsed"), @@ -261,7 +261,7 @@ fn dlg_setup_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); - let field = ui.label("Slides"); + let field = ui.label(tl!("Slides")); let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; let combo = craft_ui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { @@ -292,19 +292,19 @@ fn dlg_quit(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { } let what = if names.len() == 1 { format!("“{}”", names[0]) } else { format!("{} presentations", names.len()) }; ui.label(egui::RichText::new(format!("Do you want to save the changes you made to {what}?")).strong()); - ui.label("Your changes will be lost if you don't save them."); + ui.label(tl!("Your changes will be lost if you don't save them.")); ui.add_space(8.0); let mut done = false; ui.horizontal(|ui| { let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + if ui.add(egui::Button::new(tl!("Don't Save")).min_size(vec2(84.0, 24.0))).clicked() { let _ = app.session.execute("file.recovery.discard", &json!({})); app.quit_confirmed = true; ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); done = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + let save = ui.add(egui::Button::new(egui::RichText::new(tl!("Save")).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { if app.save_all() { app.quit_confirmed = true; @@ -312,7 +312,7 @@ fn dlg_quit(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; } }); @@ -324,17 +324,17 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { // File › Close and Ctrl+W on a presentation with unsaved changes. let Some(name) = app.session.active().map(|d| d.title()) else { return true }; ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong()); - ui.label("Your changes will be lost if you don't save them."); + ui.label(tl!("Your changes will be lost if you don't save them.")); ui.add_space(8.0); let mut done = false; ui.horizontal(|ui| { let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + if ui.add(egui::Button::new(tl!("Don't Save")).min_size(vec2(84.0, 24.0))).clicked() { run(app, "file.close", json!({})); done = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + let save = ui.add(egui::Button::new(egui::RichText::new(tl!("Save")).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { app.save(); // In the browser a save is a download: the presentation stays "unsaved". @@ -344,7 +344,7 @@ fn dlg_close(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; } }); @@ -354,7 +354,7 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { fn dlg_comment(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text(tl!("Comment")).desired_rows(4).desired_width(320.0)); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Post"); if ok && !v.trim().is_empty() { @@ -424,7 +424,7 @@ fn dlg_bullets(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ]; let color = d.get("color", ""); ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(tl!("Color:"))); let combo = craft_ui::ComboBox::from_id_salt("bullet-color") .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) .show_ui(ui, |ui| { @@ -437,7 +437,7 @@ fn dlg_bullets(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = combo.response.labelled_by(field.id); }); ui.horizontal(|ui| { - if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + if ui.button(tl!("None")).on_hover_text(tl!("Remove bullets and numbering")).clicked() { d.fields.insert("none".into(), "1".into()); } }); @@ -557,7 +557,7 @@ fn dlg_action(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "customShow" => { if shows.is_empty() { - ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); + ui.label(tl!("This presentation has no custom shows (Slide Show ▸ Custom Slide Show).")); } let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); for n in &shows { @@ -602,9 +602,9 @@ fn dlg_alt_text(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { (cur, deco) }) .unwrap_or_default(); - ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); + ui.label(tl!("How would you describe this object and its context to someone who is blind or has low vision?")); let mut v = d.get("text", &cur); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text(tl!("Alt text")).desired_rows(4).desired_width(340.0)); d.fields.insert("text".into(), v.clone()); let deco = check(ui, d, "Mark as decorative", "deco", deco_now); let (ok, cancel) = buttons(ui, "OK"); @@ -615,9 +615,9 @@ fn dlg_alt_text(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } fn dlg_outline(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); + ui.label(tl!("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets.")); let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); + ui.add(egui::TextEdit::multiline(&mut v).hint_text(tl!("Outline")).desired_rows(10).desired_width(380.0).code_editor()); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Insert"); if ok { @@ -645,12 +645,12 @@ fn dlg_reuse_slides(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { // Unticked slides are remembered by index; everything starts ticked. let off = |d: &Dialog, i: usize| d.fields.get(&format!("off{i}")).is_some_and(|v| v == "1"); ui.horizontal(|ui| { - if ui.small_button("Select All").clicked() { + if ui.small_button(tl!("Select All")).clicked() { for (i, _) in &slides { d.fields.remove(&format!("off{i}")); } } - if ui.small_button("Select None").clicked() { + if ui.small_button(tl!("Select None")).clicked() { for (i, _) in &slides { d.fields.insert(format!("off{i}"), "1".into()); } @@ -670,7 +670,7 @@ fn dlg_reuse_slides(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); let mut keep = d.params.get("keep").and_then(Value::as_bool).unwrap_or(false); - if ui.checkbox(&mut keep, "Keep source formatting").changed() + if ui.checkbox(&mut keep, tl!("Keep source formatting")).changed() && let Some(o) = d.params.as_object_mut() { o.insert("keep".into(), json!(keep)); @@ -705,7 +705,7 @@ fn dlg_rename_section(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } fn dlg_export(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label("Export the presentation as:"); + ui.label(tl!("Export the presentation as:")); let fmt = d.get("fmt", "pdf"); for (l, f) in [ ("PDF document (.pdf)", "pdf"), @@ -725,7 +725,7 @@ fn dlg_export(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if fmt == "pdf" { ui.add_space(4.0); ui.horizontal(|ui| { - let field = ui.label("Print layout:"); + let field = ui.label(tl!("Print layout:")); let combo = craft_ui::ComboBox::from_id_salt("pdf_layout") .selected_text(match pdf_layout.as_str() { "notes" => "Notes Pages", @@ -756,7 +756,7 @@ fn dlg_export(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = combo.response.labelled_by(field.id); }); let mut hidden = d.get("pdfHidden", "false") == "true"; - if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + if ui.checkbox(&mut hidden, tl!("Include hidden slides")).changed() { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } @@ -765,8 +765,8 @@ fn dlg_export(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.add_space(4.0); let mut clear = d.get("pngTransparent", "false") == "true"; if ui - .checkbox(&mut clear, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn") + .checkbox(&mut clear, tl!("Transparent background")) + .on_hover_text(tl!("Leave the slide background out: only what is on the slide is drawn")) .changed() { d.fields.insert("pngTransparent".into(), clear.to_string()); @@ -810,7 +810,7 @@ fn dlg_replace_fonts(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); let mut to = d.get("to", ""); egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let field = ui.label("Replace:"); + let field = ui.label(tl!("Replace:")); let combo = craft_ui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { for f in &used { ui.selectable_value(&mut from, f.clone(), f); @@ -818,9 +818,9 @@ fn dlg_replace_fonts(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); let _ = combo.response.labelled_by(field.id); ui.end_row(); - ui.label("With:"); + ui.label(tl!("With:")); ui.vertical(|ui| { - ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text(tl!("Font name"))); let needle = to.trim().to_lowercase(); egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { for f in crate::ribbon::font_families(app).iter().filter(|f| needle.is_empty() || f.to_lowercase().contains(&needle)).take(200) { @@ -850,7 +850,7 @@ fn dlg_replace_fonts(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } fn dlg_symbol(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { - ui.label("Click a symbol to insert it at the insertion point."); + ui.label(tl!("Click a symbol to insert it at the insertion point.")); let groups: [(&str, &str); 6] = [ ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), ("Currency", "$€£¥¢₹₩₽₺₿"), @@ -877,9 +877,9 @@ fn dlg_symbol(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { } fn dlg_equation(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); + ui.label(tl!("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a).")); let mut v = d.get("eq", ""); - ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); + ui.add(egui::TextEdit::singleline(&mut v).hint_text(tl!("Equation")).desired_width(340.0).font(theme::font(15.0))); d.fields.insert("eq".into(), v.clone()); let (ok, cancel) = buttons(ui, "Insert"); if ok && !v.is_empty() { @@ -903,7 +903,7 @@ fn dlg_paste_special(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ]; let has_shapes = !app.session.clipboard.shapes.is_empty(); let mut cur = d.get("as", "destinationTheme"); - ui.label("As:"); + ui.label(tl!("As:")); for (id, label) in OPTS { let ok = has_shapes || matches!(id, "destinationTheme" | "text"); ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); @@ -944,7 +944,7 @@ fn dlg_spelling(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ), }; if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); + ui.label(tl!("The spelling check is complete. No issues found.")); } let mut add_ignore = None; egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { @@ -954,14 +954,14 @@ fn dlg_spelling(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let shape = item.get("shape").cloned().unwrap_or_default(); ui.horizontal(|ui| { ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { + if ui.small_button(tl!("Go to")).clicked() { let _ = app.run("slide.go", json!({"index": slide})); let _ = app.run("edit.select", json!({"ids": [shape]})); } - if ui.small_button("Ignore All").clicked() { + if ui.small_button(tl!("Ignore All")).clicked() { add_ignore = Some(w.to_string()); } - if ui.small_button("Add to Dictionary").clicked() + if ui.small_button(tl!("Add to Dictionary")).clicked() && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) { app.set_status(e.to_string()); @@ -975,8 +975,8 @@ fn dlg_spelling(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if item.get("repeated").and_then(Value::as_bool).unwrap_or(false) && !item.get("notes").and_then(Value::as_bool).unwrap_or(false) { ui.horizontal(|ui| { ui.add_space(16.0); - ui.label(egui::RichText::new("Repeated word").weak()); - if ui.small_button("Delete Repeated Word").clicked() { + ui.label(egui::RichText::new(tl!("Repeated word")).weak()); + if ui.small_button(tl!("Delete Repeated Word")).clicked() { let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -1004,9 +1004,9 @@ fn dlg_spelling(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !sug.is_empty() { ui.horizontal_wrapped(|ui| { ui.add_space(16.0); - ui.label(egui::RichText::new("Change to:").weak()); + ui.label(egui::RichText::new(tl!("Change to:")).weak()); for to in &sug { - let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + let r = ui.small_button(to.as_str()).on_hover_text(tl!("Click to change this one; Shift-click to change all")); if r.clicked() { let all = ui.input(|i| i.modifiers.shift); if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { @@ -1063,7 +1063,7 @@ fn dlg_accessibility(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { let v = app.session.execute("review.accessibility", &json!({})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); if list.is_empty() { - ui.label("No accessibility issues found. People with disabilities should not have difficulty reading this document."); + ui.label(tl!("No accessibility issues found. People with disabilities should not have difficulty reading this document.")); } egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for item in &list { @@ -1074,7 +1074,7 @@ fn dlg_accessibility(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { item.get("issue").and_then(Value::as_str).unwrap_or(""), item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() )); - if ui.small_button("Go to").clicked() { + if ui.small_button(tl!("Go to")).clicked() { let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); if let Some(id) = item.get("shape") { let _ = app.run("edit.select", json!({"ids": [id]})); @@ -1096,7 +1096,7 @@ fn dlg_language(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); let mut all = !has_sel || d.get("scope", "") == "all"; ui.horizontal(|ui| { - ui.label("Apply to:"); + ui.label(tl!("Apply to:")); if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { d.fields.insert("scope".into(), "sel".into()); all = false; @@ -1123,8 +1123,8 @@ fn dlg_language(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); ui.add_space(4.0); - ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); - if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + ui.label(egui::RichText::new(tl!("Spelling is checked for languages marked (dictionary).")).small().weak()); + if ui.checkbox(&mut no_proof, tl!("Do not check spelling or grammar")).changed() { d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); } let (ok, cancel) = buttons(ui, "OK"); @@ -1141,11 +1141,11 @@ fn dlg_custom_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { for s in &shows { ui.horizontal(|ui| { ui.label(format!("{} ({} slides)", s.name, s.slides.len())); - if ui.small_button("Show").clicked() { + if ui.small_button(tl!("Show")).clicked() { run(app, "show.start", json!({"custom": s.name})); started = true; } - if ui.small_button("Delete").clicked() { + if ui.small_button(tl!("Delete")).clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); @@ -1171,11 +1171,11 @@ fn dlg_chart_data(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { .find_map(|x| if let deckcraft_model::ShapeKind::Chart(c) = &x.kind { Some(((**c).clone(), x.id)) } else { None }) }); let Some((c, id)) = ch else { - ui.label("Select a chart first."); + ui.label(tl!("Select a chart first.")); let (ok, cancel) = buttons(ui, "Close"); return ok || cancel; }; - ui.label("Edit the data: first row = series names, first column = categories."); + ui.label(tl!("Edit the data: first row = series names, first column = categories.")); let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; for (i, cat) in c.categories.iter().enumerate() { @@ -1190,17 +1190,17 @@ fn dlg_chart_data(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { egui::Grid::new("chartdata").striped(true).show(ui, |ui| { for row in grid.iter_mut() { for cell in row.iter_mut() { - ui.add(egui::TextEdit::singleline(cell).hint_text("Chart cell").desired_width(70.0)); + ui.add(egui::TextEdit::singleline(cell).hint_text(tl!("Chart cell")).desired_width(70.0)); } ui.end_row(); } }); ui.horizontal(|ui| { - if ui.button("+ Row").clicked() { + if ui.button(tl!("+ Row")).clicked() { let n = grid.first().map(Vec::len).unwrap_or(2); grid.push(vec![String::new(); n]); } - if ui.button("+ Series").clicked() { + if ui.button(tl!("+ Series")).clicked() { for r in grid.iter_mut() { r.push(String::new()); } @@ -1240,7 +1240,7 @@ fn dlg_trim(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } fn dlg_smartart(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label("Pick a graphic; type one item per line."); + ui.label(tl!("Pick a graphic; type one item per line.")); let kind = d.get("kind", "process"); ui.horizontal_wrapped(|ui| { for (k, l) in [ @@ -1257,7 +1257,7 @@ fn dlg_smartart(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); let mut v = d.get("text", "Plan\nBuild\nLaunch"); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("SmartArt items").desired_rows(5).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text(tl!("SmartArt items")).desired_rows(5).desired_width(320.0)); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Insert"); if ok { @@ -1289,23 +1289,23 @@ fn dlg_about(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { fn dlg_autocorrect(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let pr = &mut app.session.prefs; - ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); - ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); - ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); - ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); - ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); - ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); - ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); - ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); + ui.checkbox(&mut pr.ac_two_initials, tl!("Correct TWo INitial CApitals")); + ui.checkbox(&mut pr.ac_sentence_caps, tl!("Capitalize first letter of sentences")); + ui.checkbox(&mut pr.ac_days, tl!("Capitalize names of days")); + ui.checkbox(&mut pr.ac_caps_lock, tl!("Correct accidental use of cAPS LOCK key")); + ui.checkbox(&mut pr.ac_fractions, tl!("Replace fractions (1/2) with fraction characters (½)")); + ui.checkbox(&mut pr.ac_ordinals, tl!("Ordinals (1st) with superscript")); + ui.checkbox(&mut pr.ac_lists, tl!("Automatic bulleted and numbered lists")); + ui.checkbox(&mut pr.autocorrect, tl!("Replace text as you type")); ui.add_space(4.0); let mut from = d.get("from", ""); let mut to = d.get("to", ""); ui.horizontal(|ui| { - let l = ui.label("Replace"); + let l = ui.label(tl!("Replace")); ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); - let l = ui.label("With"); + let l = ui.label(tl!("With")); ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); - if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new(tl!("Add"))).clicked() { run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); from.clear(); to.clear(); @@ -1321,7 +1321,7 @@ fn dlg_autocorrect(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { ui.label(k); ui.label(v); - if ui.small_button("Delete").clicked() { + if ui.small_button(tl!("Delete")).clicked() { run(app, "text.autocorrect", json!({"remove": k})); } ui.end_row(); @@ -1338,9 +1338,9 @@ fn dlg_preferences(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 @@ -1351,7 +1351,7 @@ fn dlg_preferences(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()); @@ -1362,20 +1362,21 @@ fn dlg_preferences(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.remove("uiScale"); } + language_row(app, ui); // 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"); } }); @@ -1383,7 +1384,7 @@ fn dlg_preferences(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); diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index b133efe189..2e39676756 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -215,14 +215,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label("Show"); + ui.label(tl!("Show")); for m in NameMode::ALL { if ui.selectable_label(v.names == m, m.label()).clicked() { v.names = m; } } ui.separator(); - let field = ui.label("Sort"); + let field = ui.label(tl!("Sort")); let combo = craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { @@ -232,7 +232,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } }); let _ = combo.response.labelled_by(field.id); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); @@ -248,14 +248,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.separator(); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { if list.is_empty() { - ui.label("No contributor data was built into this copy."); + ui.label(tl!("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v); } else { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { - ui.label(RichText::new("·").weak()); + ui.label(RichText::new(tl!("·")).weak()); } ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); } @@ -298,7 +298,7 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { /// About ▸ Models: AI models credited in Co-Authored-By trailers. pub fn models_ui(ui: &mut egui::Ui) { if MODELS.is_empty() { - ui.label("No model credits were built into this copy."); + ui.label(tl!("No model credits were built into this copy.")); return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); diff --git a/crates/ui-egui/src/dialogs/date_time.rs b/crates/ui-egui/src/dialogs/date_time.rs index 3ba1c3d7ef..1a858ec41f 100644 --- a/crates/ui-egui/src/dialogs/date_time.rs +++ b/crates/ui-egui/src/dialogs/date_time.rs @@ -30,7 +30,7 @@ pub(super) fn date_rows(lang: &str) -> Vec<(&'static str, String)> { /// whether the dialog should close. pub(super) fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.set_min_width(440.0); - ui.label("Available formats:"); + ui.label(tl!("Available formats:")); let lang = d.get("language", "en-US"); let rows = date_rows(&lang); if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1")) @@ -48,7 +48,7 @@ pub(super) fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) }); let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); ui.horizontal(|ui| { - ui.label("Language:"); + ui.label(tl!("Language:")); let opened = craft_ui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { for (k, l) in DATE_LANGS { if ui.selectable_label(lang == *k, *l).clicked() { @@ -64,7 +64,7 @@ pub(super) fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) }); }); ui.label( - egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") + egui::RichText::new(tl!("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.")) .size(11.0) .color(Tokens::get(ui.ctx()).text_dim), ); diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs index 432ac58ef6..7968b32f6f 100644 --- a/crates/ui-egui/src/dialogs/export.rs +++ b/crates/ui-egui/src/dialogs/export.rs @@ -15,7 +15,7 @@ pub(super) fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result }; ui.add_space(4.0); ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(tl!("Size:"))); let combo = craft_ui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { if ui.selectable_label(size == k, size_label(k)).clicked() { diff --git a/crates/ui-egui/src/dialogs/find.rs b/crates/ui-egui/src/dialogs/find.rs index c702db9260..8bcc62610f 100644 --- a/crates/ui-egui/src/dialogs/find.rs +++ b/crates/ui-egui/src/dialogs/find.rs @@ -6,7 +6,7 @@ pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo // "Find what:" has the keyboard when the dialog opens, so Ctrl+F and typing finds (#951). let mut what = d.get("text", ""); ui.horizontal(|ui| { - ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Find what:")); + ui.add_sized(vec2(120.0, 20.0), egui::Label::new(tl!("Find what:"))); let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(180.0)); // Enter finds the next match and leaves the keyboard here, so the search can be changed. if !d.fields.contains_key("focused") || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { @@ -27,11 +27,11 @@ pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo ui.add_space(6.0); ui.horizontal(|ui| { let has = !text.is_empty(); - if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + if ui.add_enabled(has, egui::Button::new(tl!("Find Next"))).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { find_next = true; } if replace_mode { - if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + if ui.add_enabled(has, egui::Button::new(tl!("Replace"))).clicked() { let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); if let Some(at) = cur { let mut p = q.clone(); @@ -44,7 +44,7 @@ pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } find_next = true; } - if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + if ui.add_enabled(has, egui::Button::new(tl!("Replace All"))).clicked() { let mut p = q.clone(); p["with"] = json!(d.get("with", "")); let msg = match app.session.execute("text.replace", &p) { @@ -54,10 +54,10 @@ pub(super) fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo d.fields.insert("status".into(), msg); d.fields.remove("cur"); } - } else if ui.button("Replace…").clicked() { + } else if ui.button(tl!("Replace…")).clicked() { d.fields.insert("replace".into(), "1".into()); } - if ui.button("Close").clicked() { + if ui.button(tl!("Close")).clicked() { close = true; } }); diff --git a/crates/ui-egui/src/dialogs/font.rs b/crates/ui-egui/src/dialogs/font.rs index a163e7bce4..ea3a18850d 100644 --- a/crates/ui-egui/src/dialogs/font.rs +++ b/crates/ui-egui/src/dialogs/font.rs @@ -25,7 +25,7 @@ pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo fams.insert(0, family.clone()); } ui.horizontal(|ui| { - let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new(tl!("Font"))); let combo = craft_ui::ComboBox::from_id_salt("font_dialog_family") .selected_text(if family.is_empty() { " " } else { family.as_str() }) .width(300.0) @@ -47,7 +47,7 @@ pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo check(ui, d, "Italic", "italic", false); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(tl!("Underline"))); choice( ui, d, @@ -60,20 +60,20 @@ pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo let _ = labeled_text(ui, d, "Color", "color", 110.0); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(tl!("Strikethrough"))); choice(ui, d, "font_dialog_strike", "Strikethrough", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(tl!("Baseline"))); choice(ui, d, "font_dialog_baseline", "Baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); ui.add_space(8.0); - ui.label("Caps"); + ui.label(tl!("Caps")); choice(ui, d, "font_dialog_caps", "Caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(tl!("Spacing"))); let mut spacing = d.get("spacing", "0"); - let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); + let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text(tl!("pt"))); let mut picked = None; let combo = craft_ui::ComboBox::from_id_salt("font_dialog_spacing") .selected_text(match spacing.as_str() { @@ -105,7 +105,7 @@ pub(super) fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo let underline = d.get("underline", "none"); let strike = d.get("strike", "none"); let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32; - let mut sample = egui::RichText::new("AaBbCcDd").size(shown); + let mut sample = egui::RichText::new(tl!("AaBbCcDd")).size(shown); if bold { sample = sample.strong(); } diff --git a/crates/ui-egui/src/dialogs/grid_options.rs b/crates/ui-egui/src/dialogs/grid_options.rs index 0e7f9c979c..3313ba9b2c 100644 --- a/crates/ui-egui/src/dialogs/grid_options.rs +++ b/crates/ui-egui/src/dialogs/grid_options.rs @@ -10,13 +10,13 @@ fn format_spacing(v: f64) -> String { /// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one /// `view.gridOptions` call; Cancel leaves the presentation as it was. pub(super) fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Snap")).font(theme::bold(13.0))); let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); let spacing_default = format_spacing(app.session.prefs.grid_spacing); field(ui, d, "Spacing (pt):", "spacing", &spacing_default); ui.add_space(6.0); - ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Display")).font(theme::bold(13.0))); let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { diff --git a/crates/ui-egui/src/dialogs/paragraph.rs b/crates/ui-egui/src/dialogs/paragraph.rs index 217076c35c..b7901233bb 100644 --- a/crates/ui-egui/src/dialogs/paragraph.rs +++ b/crates/ui-egui/src/dialogs/paragraph.rs @@ -58,7 +58,7 @@ pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) ui.set_min_width(460.0); seed_paragraph_fields(d); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(tl!("Alignment"))); choice( ui, d, @@ -75,32 +75,32 @@ pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) ], ); }); - ui.label(egui::RichText::new("Indentation").strong()); + ui.label(egui::RichText::new(tl!("Indentation")).strong()); ui.horizontal(|ui| { let _ = labeled_text(ui, d, "Before text", "before", 64.0); - ui.label("in"); + ui.label(tl!("in")); ui.add_space(12.0); let _ = labeled_text(ui, d, "After text", "after", 64.0); - ui.label("in"); + ui.label(tl!("in")); }); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(tl!("Special"))); choice(ui, d, "paragraph_special", "Special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); ui.add_space(8.0); let _ = labeled_text(ui, d, "By", "by", 64.0); - ui.label("in"); + ui.label(tl!("in")); }); - ui.label(egui::RichText::new("Spacing").strong()); + ui.label(egui::RichText::new(tl!("Spacing")).strong()); ui.horizontal(|ui| { let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); - ui.label("pt"); + ui.label(tl!("pt")); ui.add_space(12.0); let _ = labeled_text(ui, d, "Spacing after", "spaceAfter", 64.0); - ui.label("pt"); + ui.label(tl!("pt")); }); let mut picked: Option<(&'static str, Option<&'static str>)> = None; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new(tl!("Line spacing"))); let kind = d.get("lineKind", "multiple"); let value = d.get("lineValue", "1"); let combo = @@ -133,7 +133,7 @@ pub(super) fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) } let mut open_tabs = false; ui.horizontal(|ui| { - if ui.button("Tabs…").clicked() { + if ui.button(tl!("Tabs…")).clicked() { open_tabs = true; } }); diff --git a/crates/ui-egui/src/dialogs/photo_album.rs b/crates/ui-egui/src/dialogs/photo_album.rs index 340e5832c0..75e8caa96d 100644 --- a/crates/ui-egui/src/dialogs/photo_album.rs +++ b/crates/ui-egui/src/dialogs/photo_album.rs @@ -154,13 +154,13 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog ui.set_min_width(440.0); egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| { ui.set_min_width(420.0); - ui.label("Pictures in album"); + ui.label(tl!("Pictures in album")); let items = album_items(d).unwrap_or_default(); let mut sel = d.get("sel", "0").parse::().unwrap_or(0); // Fixed height so adding pictures does not push Create past the window. egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| { if items.is_empty() { - ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(tl!("No pictures yet")).color(Tokens::get(ui.ctx()).text_dim)); } for (i, item) in items.iter().enumerate() { if ui.selectable_label(sel == i, album_item_label(item)).clicked() { @@ -170,7 +170,7 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog }); d.fields.insert("sel".into(), sel.to_string()); ui.horizontal(|ui| { - if ui.button("Add Pictures…").clicked() + if ui.button(tl!("Add Pictures…")).clicked() && let Some(item) = pick_album_picture(app) { match album_items(d) { @@ -184,7 +184,7 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog None => app.set_status("The picture list is invalid"), } } - if ui.button("New Text Box").clicked() { + if ui.button(tl!("New Text Box")).clicked() { let text = d.get("boxText", "Text Box"); let text = text.trim(); if text.is_empty() { @@ -202,7 +202,7 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog } } } - if ui.button("Remove").clicked() + if ui.button(tl!("Remove")).clicked() && let Some(mut list) = album_items(d) && let Some(i) = d.get("sel", "0").parse::().ok() && i < list.len() @@ -218,36 +218,36 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog ui.label(album_picture_status(&items, sel)); ui.horizontal_wrapped(|ui| { ui.add_enabled_ui(picture, |ui| { - if ui.button("Rotate Left").clicked() { + if ui.button(tl!("Rotate Left")).clicked() { with_selected_picture(d, |obj| { let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0; obj.insert("rot".into(), json!(next)); }); } - if ui.button("Rotate Right").clicked() { + if ui.button(tl!("Rotate Right")).clicked() { with_selected_picture(d, |obj| { let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0; obj.insert("rot".into(), json!(next)); }); } - if ui.button("More Brightness").clicked() { + if ui.button(tl!("More Brightness")).clicked() { nudge_picture(d, "brightness", 0.1); } - if ui.button("Less Brightness").clicked() { + if ui.button(tl!("Less Brightness")).clicked() { nudge_picture(d, "brightness", -0.1); } - if ui.button("More Contrast").clicked() { + if ui.button(tl!("More Contrast")).clicked() { nudge_picture(d, "contrast", 0.1); } - if ui.button("Less Contrast").clicked() { + if ui.button(tl!("Less Contrast")).clicked() { nudge_picture(d, "contrast", -0.1); } }); }); ui.add_space(4.0); - ui.label("Picture layout"); + ui.label(tl!("Picture layout")); album_choice( ui, d, @@ -265,7 +265,7 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog ("4title", "4 pictures with title"), ], ); - ui.label("Frame shape"); + ui.label(tl!("Frame shape")); album_choice( ui, d, @@ -277,7 +277,7 @@ pub(super) fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog ); check(ui, d, "Captions under pictures", "captions", false); check(ui, d, "ALL pictures black and white", "grayscale", false); - ui.label("Album theme"); + ui.label(tl!("Album theme")); album_choice(ui, d, "album_theme", "Album theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); }); let captions = d.fields.get("captions").is_some_and(|v| v == "1"); diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index b0b98fe552..a756e493db 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -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,7 +21,31 @@ 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) { + let _ = app.run("app.language", json!({ "language": language })); + } if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) { app.session.prefs = prefs; } } + +/// Preferences › Interface language: follow the system, or one of the languages this build has +/// catalogs for, each named in itself. It applies at once, like the other controls here. +pub(super) fn language_row(app: &mut SlideApp, ui: &mut egui::Ui) { + let auto = crate::i18n::AUTO; + let name = |code: &str| if code == auto { tl!("System language").to_string() } else { crate::i18n::Lang::from_pref(code).name().to_string() }; + let mut chosen = app.ui.language.clone(); + ui.horizontal(|ui| { + ui.label(tl!("Interface language:")); + let combo = craft_ui::ComboBox::from_id_salt("preferences.language").selected_text(name(&chosen)).width(160.0).show_ui(ui, |ui| { + ui.selectable_value(&mut chosen, auto.to_string(), name(auto)); + for lang in crate::i18n::Lang::all() { + ui.selectable_value(&mut chosen, lang.code().to_string(), lang.name()); + } + }); + combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, tl!("Interface language"))); + }); + if chosen != app.ui.language { + let _ = app.run("app.language", json!({ "language": chosen })); + } +} diff --git a/crates/ui-egui/src/dialogs/size_position.rs b/crates/ui-egui/src/dialogs/size_position.rs index 73e6274f0f..da4ecc283d 100644 --- a/crates/ui-egui/src/dialogs/size_position.rs +++ b/crates/ui-egui/src/dialogs/size_position.rs @@ -180,7 +180,7 @@ fn size_position_value(d: &Dialog) -> Result { pub(super) fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { seed_size_position(app, d); - ui.label(egui::RichText::new("Size").strong()); + ui.label(egui::RichText::new(tl!("Size")).strong()); text_row(ui, d, "Height (in):", "h"); text_row(ui, d, "Width (in):", "w"); text_row(ui, d, "Rotation (°):", "rot"); @@ -188,8 +188,8 @@ pub(super) fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dial text_row(ui, d, "Scale width (%):", "scaleW"); check(ui, d, "Lock aspect ratio", "lock", false); ui.add_space(8.0); - ui.label(egui::RichText::new("Position").strong()); - ui.label("From the top-left corner of the slide"); + ui.label(egui::RichText::new(tl!("Position")).strong()); + ui.label(tl!("From the top-left corner of the slide")); text_row(ui, d, "Horizontal (in):", "x"); text_row(ui, d, "Vertical (in):", "y"); reconcile_size_fields(d); diff --git a/crates/ui-egui/src/dialogs/tabs.rs b/crates/ui-egui/src/dialogs/tabs.rs index a74760c556..9943d6f29b 100644 --- a/crates/ui-egui/src/dialogs/tabs.rs +++ b/crates/ui-egui/src/dialogs/tabs.rs @@ -26,17 +26,17 @@ pub(super) fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo .collect(); field(ui, d, "Default tab stops (in):", "default", "1"); ui.add_space(6.0); - ui.label("Tab stops:"); + ui.label(tl!("Tab stops:")); let mut remove = None; egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { if stops.is_empty() { - ui.label("(none)"); + ui.label(tl!("(none)")); ui.end_row(); } for (i, (pos, align)) in stops.iter().enumerate() { ui.label(format!("{pos:.2}\"")); ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); - if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + if ui.small_button(tl!("Clear")).on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { remove = Some(i); } ui.end_row(); @@ -47,14 +47,14 @@ pub(super) fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } ui.add_space(6.0); ui.horizontal(|ui| { - let l = ui.label("Tab stop position (in):"); + let l = ui.label(tl!("Tab stop position (in):")); let mut v = d.get("newpos", ""); ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id); d.fields.insert("newpos".into(), v); }); let cur = d.get("newalign", "l"); ui.horizontal(|ui| { - ui.label("Alignment:"); + ui.label(tl!("Alignment:")); for (k, label) in TAB_KINDS { if ui.radio(cur == k, label).clicked() { d.fields.insert("newalign".into(), k.into()); @@ -62,7 +62,7 @@ pub(super) fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } }); ui.horizontal(|ui| { - if ui.button("Set").clicked() + if ui.button(tl!("Set")).clicked() && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) { stops.retain(|(q, _)| (q - p).abs() > 0.005); @@ -70,7 +70,7 @@ pub(super) fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo stops.sort_by(|a, b| a.0.total_cmp(&b.0)); d.fields.insert("newpos".into(), String::new()); } - if ui.button("Clear All").clicked() { + if ui.button(tl!("Clear All")).clicked() { stops.clear(); } }); diff --git a/crates/ui-egui/src/dialogs/theme_colors.rs b/crates/ui-egui/src/dialogs/theme_colors.rs index 250dfea491..d83636c680 100644 --- a/crates/ui-egui/src/dialogs/theme_colors.rs +++ b/crates/ui-egui/src/dialogs/theme_colors.rs @@ -70,7 +70,7 @@ pub(super) fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialo ui.add_space(6.0); let mut name = d.get("schemeName", ""); ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(tl!("Name:")); let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); let shown = name.clone(); resp.widget_info(move || { @@ -80,7 +80,7 @@ pub(super) fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialo }); }); d.fields.insert("schemeName".into(), name); - ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(tl!("Applies to this presentation's theme colours.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim)); let (ok, cancel) = buttons(ui, "Save"); if ok { match theme_color_payload(d) { diff --git a/crates/ui-egui/src/dialogs/theme_fonts.rs b/crates/ui-egui/src/dialogs/theme_fonts.rs index 0adcd3372d..0e3b671be2 100644 --- a/crates/ui-egui/src/dialogs/theme_fonts.rs +++ b/crates/ui-egui/src/dialogs/theme_fonts.rs @@ -72,11 +72,11 @@ pub(super) fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog let mut major = major; let mut minor = minor; ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Heading:")); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(tl!("Heading:"))); font_combo(ui, "Heading font", &headings, &mut major); }); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Body:")); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(tl!("Body:"))); font_combo(ui, "Body font", &bodies, &mut minor); }); d.fields.insert("major".into(), major.clone()); @@ -89,7 +89,7 @@ pub(super) fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog ui.add_space(4.0); let mut name = d.get("schemeName", ""); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Name:")); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(tl!("Name:"))); let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName")); let shown = name.clone(); resp.widget_info(move || { @@ -99,7 +99,7 @@ pub(super) fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog }); }); d.fields.insert("schemeName".into(), name); - ui.label(egui::RichText::new("Heading and body fonts for this presentation.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(tl!("Heading and body fonts for this presentation.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim)); let (ok, cancel) = buttons(ui, "Save"); if ok { match theme_font_payload(d) { diff --git a/crates/ui-egui/src/dialogs/zoom.rs b/crates/ui-egui/src/dialogs/zoom.rs index e257b9f954..be906ac411 100644 --- a/crates/ui-egui/src/dialogs/zoom.rs +++ b/crates/ui-egui/src/dialogs/zoom.rs @@ -23,7 +23,7 @@ pub(super) fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } let mut mode = d.get("mode", "percent"); let mut percent = d.get("percent", "100"); - ui.label("Zoom to"); + ui.label(tl!("Zoom to")); if ui.radio(mode == "fit", "Fit").clicked() { mode = "fit".into(); } @@ -35,12 +35,12 @@ pub(super) fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bo } } ui.horizontal(|ui| { - let l = ui.label("Percent:"); + let l = ui.label(tl!("Percent:")); let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id); if edit.changed() { mode = "percent".into(); } - ui.label("%"); + ui.label(tl!("%")); }); d.fields.insert("mode".into(), mode.clone()); d.fields.insert("percent".into(), percent.clone()); diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index 44c739aed4..535b68d5d7 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -96,10 +96,10 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.add_space(16.0); ui.label(RichText::new(format!("Slide {} of {n}", slide + 1)).color(t.text_dim).font(crate::theme::font(13.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add_enabled(slide + 1 < n, egui::Button::new("Next")).clicked() { + if ui.add_enabled(slide + 1 < n, egui::Button::new(tl!("Next"))).clicked() { let _ = app.run("view.newWindow", json!({"delta": 1})); } - if ui.add_enabled(slide > 0, egui::Button::new("Previous")).clicked() { + if ui.add_enabled(slide > 0, egui::Button::new(tl!("Previous"))).clicked() { let _ = app.run("view.newWindow", json!({"delta": -1})); } }); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 0000000000..7ac3b65f74 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,241 @@ +//! Interface languages (#15). Strings in the code stay English and are the lookup keys; the +//! craft-i18n catalogs map them to display text when the UI is drawn. Command ids, labels the +//! engine returns, presentation text, file names, the control channel, the CLI and MCP never see +//! translated text, so scripts and agents are unaffected. A string without a translation shows in +//! English, so coverage can grow one entry at a time, and a missing translation never fails a +//! build, a test or a release. +//! +//! The catalogs live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n) +//! (`locales//common.tsv` and `deckcraft.tsv`), not in this repository. `build.rs` embeds +//! them from `CRAFT_I18N_DIR` (CI and releases check out the SHA in `craft-i18n.rev`); without it +//! the build is English only. Lookup, fallback (`de-at` → `de` → English, app rows over shared +//! ones), plurals, language names and locale matching are the `craft-i18n` crate's (craft-libs). +//! +//! The `language` setting is `auto` by default: the UI follows the operating system's language +//! (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 and the +//! `app.language` command pick one by hand. +//! +//! # Adding or changing a translation +//! In craft-i18n: add rows to `locales//deckcraft.tsv` (its `docs/integration.md`); they +//! show here once `craft-i18n.rev` moves to a commit that has them. A feature PR here adds the +//! English only. +//! +//! # Adding a language +//! 1. craft-i18n gets `locales//deckcraft.tsv` (its `common.tsv` already names the language). +//! 2. Add its code to [`LANGUAGES`]. +//! +//! The Preferences dropdown, `app.language` and the system-language match pick it up from there. +//! +//! # Looking strings up +//! - [`tl!`](crate::tl) / [`t`]: a string in the current language. [`tr`]: in a given one. +//! - [`tc`]: worded for a context, for an English word with two meanings. +//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment. +//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them. + +use std::cell::Cell; +use std::sync::{LazyLock, OnceLock}; + +use craft_i18n::I18n; + +pub use craft_i18n::{fmt, fmt_plain}; + +include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs")); + +/// The embedded catalogs, offering exactly the languages in [`LANGUAGES`]. Built on first use; +/// a locale is parsed on its first lookup. +static I18N: LazyLock = LazyLock::new(|| { + let (i18n, problems) = I18n::new(CRAFT_I18N, &LANGUAGES); + problems.iter().for_each(|p| log::warn!("interface catalogs: {p}")); + i18n +}); + +/// The `language` setting that follows the system. +pub const AUTO: &str = "auto"; + +/// The languages DeckCraft offers, as lowercase BCP 47 codes (also the `language` setting's value +/// and the craft-i18n locale), when their catalogs are embedded. English first: it is the source +/// language and the fallback. +pub static LANGUAGES: [&str; 2] = [ + "en", // German, neutral across Germany, Austria and Switzerland; `de-DE`, `de-AT`, `de_CH.UTF-8` … + // resolve here. + "de", +]; + +/// A language the UI can be shown in: English, or an offered language whose catalogs are embedded. +#[derive(Clone, Copy)] +pub struct Lang { + lang: craft_i18n::Lang, + /// The catalogs it is looked up in: the embedded ones ([`I18N`]), or a test fixture's. + catalogs: Option<&'static I18n>, +} + +impl std::fmt::Debug for Lang { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Lang({})", self.code()) + } +} + +impl PartialEq for Lang { + fn eq(&self, other: &Self) -> bool { + self.code() == other.code() + } +} + +impl Eq for Lang {} + +impl Lang { + pub const EN: Lang = Lang { lang: craft_i18n::Lang::EN, catalogs: None }; + + fn i18n(self) -> &'static I18n { + self.catalogs.unwrap_or_else(|| LazyLock::force(&I18N)) + } + + pub fn code(self) -> &'static str { + self.i18n().code(self.lang) + } + + /// The language's name in itself (its `@id language.name` row), shown in the Preferences list. + pub fn name(self) -> &'static str { + self.i18n().name(self.lang) + } + + /// A language by its exact code (any case): one of [`LANGUAGES`] whose catalogs are embedded. + pub fn from_code(code: &str) -> Option { + let code = LANGUAGES.iter().find(|l| l.eq_ignore_ascii_case(code.trim()))?; + #[cfg(test)] + if let Some(l) = FIXTURE.get().filter(|l| l.code() == *code) { + return Some(l); + } + I18N.from_code(code).map(|lang| Lang { lang, catalogs: None }) + } + + /// 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 language the interface can be shown in: [`LANGUAGES`] in order, those with embedded + /// catalogs (English only in a build without craft-i18n). + pub fn all() -> impl Iterator { + LANGUAGES.iter().filter_map(|l| Lang::from_code(l)) + } +} + +/// The canonical `language` setting for user input: [`AUTO`] or an offered code (any case). +/// `None` for anything else, so callers keep the current setting. +pub fn normalize_pref(pref: &str) -> Option<&'static str> { + if pref.trim().eq_ignore_ascii_case(AUTO) { + return Some(AUTO); + } + Lang::from_code(pref).map(Lang::code) +} + +/// The offered language for a locale tag such as `de_DE.UTF-8` or `de-AT`; `None` when there is +/// no catalog for it. `C`/`POSIX` mean English. +pub fn lang_from_tag(tag: &str) -> Option { + I18N.match_tag(tag).and_then(|lang| Lang::from_code(I18N.code(lang))) +} + +/// The first of the system's preferred languages DeckCraft has, in the user's order: someone +/// who prefers French, then German, gets German rather than English. +pub fn first_supported<'a>(tags: impl IntoIterator) -> Option { + tags.into_iter().find_map(lang_from_tag) +} + +/// The system language (looked up once). English when it can't be determined or isn't offered. +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 = OnceLock::new(); + *SYSTEM.get_or_init(detect_system_lang) +} + +fn detect_system_lang() -> Lang { + // An explicit override, then the platform's preferred UI languages. + if let Some(l) = std::env::var("DECKCRAFT_LANGUAGE").ok().and_then(|v| lang_from_tag(&v)) { + return l; + } + let tags: Vec = 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 = 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 translation for this string? (English never does: it is the source.) +pub fn has(lang: Lang, s: &str) -> bool { + lang.i18n().get(lang.lang, "", 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.i18n().t(lang.lang, s) +} + +/// `s` in the current language as worded for `context`, falling back to the plain translation and +/// then to English. +pub fn tc<'a>(context: &str, s: &'a str) -> &'a str { + let lang = current(); + lang.i18n().tc(lang.lang, context, s) +} + +/// `one` or `other` (English forms with `{n}`) for `n` in the current language's plural form. +pub fn tn(n: u64, one: &str, other: &str) -> String { + let lang = current(); + lang.i18n().trn(lang.lang, n, one, other) +} + +/// A ribbon location (`Home › Font`) in the current language, segment by segment. +pub fn location(loc: &str) -> String { + loc.split(" › ").map(t).collect::>().join(" › ") +} + +/// A test language whose DeckCraft catalog is `source` (catalog format), so tests of lookup code +/// don't depend on the embedded catalogs' wording. +#[cfg(test)] +pub(crate) fn fixture_lang(code: &'static str, source: &'static str) -> Lang { + let embedded: &'static craft_i18n::Embedded = Box::leak(Box::new([(code, "", source)])); + let (i18n, _) = I18n::new(embedded, &[code]); + let i18n: &'static I18n = Box::leak(Box::new(i18n)); + let lang = i18n.from_code(code).unwrap_or(craft_i18n::Lang::EN); + Lang { lang, catalogs: Some(i18n) } +} + +#[cfg(test)] +thread_local! { + // A fixture language standing in for the embedded catalogs of its code (see `use_fixture`). + static FIXTURE: Cell> = const { Cell::new(None) }; +} + +/// Make `code` (one of [`LANGUAGES`]) resolve to a fixture with `source` as its catalog on this +/// thread, so app tests can switch the interface whether or not craft-i18n was embedded. +#[cfg(test)] +pub(crate) fn use_fixture(code: &'static str, source: &'static str) -> Lang { + let lang = fixture_lang(code, source); + FIXTURE.set(Some(lang)); + lang +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 0000000000..0e8630da7d --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,148 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use deckcraft_engine::Session; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use super::*; +use crate::{Services, SlideApp, UiState}; + +/// A German catalog for the tests, in craft-i18n's format (context, English, translation). +const DE: &str = "@id\tlanguage.name\tDeutsch +\tHome\tStart +\tInsert\tEinfügen +\tFile\tDatei +\tNew\\nSlide\tNeue\\nFolie +\tSlide {n} of {total}\tFolie {n} von {total} +\tNotes\tNotizen +\t{name} options\tOptionen für {name} +\tSystem language\tSystemsprache +"; + +fn harness(app: SlideApp) -> Harness<'static, SlideApp> { + Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ) +} + +#[test] +fn a_string_without_a_translation_stays_english() { + let de = fixture_lang("de", DE); + assert_eq!(tr(de, "Insert"), "Einfügen"); + assert_eq!(tr(de, "New\nSlide"), "Neue\nFolie", "`\\n` in a catalog is a line break"); + assert_eq!(tr(de, "Not in the catalog"), "Not in the catalog"); + assert_eq!(tr(Lang::EN, "Insert"), "Insert"); + assert!(has(de, "Insert") && !has(Lang::EN, "Insert")); + assert_eq!(de.name(), "Deutsch"); + set_current(de); + assert_eq!(fmt(t("Slide {n} of {total}"), &[("n", "2"), ("total", "9")]), "Folie 2 von 9"); + assert_eq!(location("Home › Insert"), "Start › Einfügen"); + set_current(Lang::EN); +} + +#[test] +fn the_language_setting_is_auto_or_an_offered_code() { + assert_eq!(normalize_pref("auto"), Some(AUTO)); + assert_eq!(normalize_pref(" AUTO "), Some(AUTO)); + assert_eq!(normalize_pref("EN"), Some("en")); + for bad in ["", "xx", "en-US-x-whatever", "Deutsch"] { + assert_eq!(normalize_pref(bad), None, "{bad:?}"); + } + // Tests always resolve the system language to English, so labels can be queried in English. + assert_eq!(Lang::from_pref(AUTO), Lang::EN); + assert_eq!(Lang::from_pref("a code from a newer version"), Lang::EN); + assert_eq!(Lang::all().next(), Some(Lang::EN), "English is first and always offered"); + // A damaged or hand-edited `ui.json` follows the system. + let ui = UiState { language: "klingon".into(), ..Default::default() }.sanitized(); + assert_eq!(ui.language, AUTO); + assert_eq!(UiState { language: "EN".into(), ..Default::default() }.sanitized().language, "en"); +} + +#[test] +fn app_language_reports_and_changes_the_setting() { + use_fixture("de", DE); + let mut app = SlideApp::new(Session::with_new(), Services::default()); + let v = app.run("app.language", json!({})).unwrap(); + assert_eq!((v["language"].as_str(), v["current"].as_str()), (Some(AUTO), Some("en"))); + let codes: Vec<&str> = v["available"].as_array().unwrap().iter().filter_map(|l| l["code"].as_str()).collect(); + assert_eq!(codes, ["en", "de"]); + let v = app.run("app.language", json!({"language": "DE"})).unwrap(); + assert_eq!((v["language"].as_str(), v["current"].as_str()), (Some("de"), Some("de"))); + assert_eq!(app.ui.language, "de"); + for bad in [json!({"language": "xx"}), json!({"language": 7}), json!({"language": null})] { + assert!(app.run("app.language", bad.clone()).is_err(), "{bad}"); + assert_eq!(app.ui.language, "de", "a refused value keeps the setting"); + } + app.run("app.language", json!({"language": "auto"})).unwrap(); + assert_eq!(app.ui.language, AUTO); +} + +/// Switching the language redraws the ribbon, status bar and accessible names in German, while +/// the tab ids the control channel and `view.tab` use stay English. +#[test] +fn switching_to_german_translates_the_interface_and_back() { + use_fixture("de", DE); + let mut h = harness(SlideApp::new(Session::with_new(), Services::default())); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Insert"); + h.get_by_value("Slide 1 of 1"); + + h.state_mut().run("app.language", json!({"language": "de"})).unwrap(); + h.run_steps(3); + assert!(h.query_by_role_and_label(Role::Button, "Insert").is_none(), "the English tab name is gone"); + h.get_by_role_and_label(Role::Button, "Einfügen").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tab, "Insert", "the tab id stays English"); + h.state_mut().run("view.tab", json!({"tab": "Home"})).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Neue Folie"); + h.get_by_role_and_label(Role::Button, "Optionen für Neue Folie"); + h.get_by_role_and_label(Role::Button, "Notizen"); + h.get_by_value("Folie 1 von 1"); + h.get_by_role_and_label(Role::Button, "Datei"); + // A string the catalog lacks shows in English. + h.get_by_role_and_label(Role::Button, "Design"); + + h.state_mut().run("app.language", json!({"language": "en"})).unwrap(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Insert"); + h.get_by_value("Slide 1 of 1"); +} + +/// Cancel in Preferences puts the language back, like the other settings there. +#[test] +fn preferences_cancel_restores_the_language() { + use_fixture("de", DE); + let mut h = harness(SlideApp::new(Session::with_new(), Services::default())); + h.state_mut().run("app.preferences", json!({})).unwrap(); + h.run_steps(3); + h.state_mut().run("app.language", json!({"language": "de"})).unwrap(); + h.run_steps(2); + h.get_by_role_and_label(Role::Button, "Cancel").click(); + h.run_steps(3); + assert!(h.state().dialog.is_none()); + assert_eq!(h.state().ui.language, AUTO); +} + +/// The catalogs `build.rs` embedded parse without a problem, and every embedded locale is offered. +/// Skipped in a build without craft-i18n (`CRAFT_I18N_DIR`), which is English only. +#[test] +fn embedded_catalogs_parse_cleanly() { + if CRAFT_I18N.is_empty() { + eprintln!("skipped: built without craft-i18n (CRAFT_I18N_DIR); the interface is English only"); + return; + } + let (i18n, problems) = I18n::new(CRAFT_I18N, &LANGUAGES); + assert!(problems.is_empty(), "{problems:?}"); + let rows = i18n.check_all(); + assert!(rows.is_empty(), "{rows:#?}"); + for (code, ..) in CRAFT_I18N { + assert!(LANGUAGES.contains(code), "{code} is embedded but not offered"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 30b5a0cd5f..c9aec30d02 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +/// Interface text in the current language: `tl!("Insert")`. See [`i18n`]. +#[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; @@ -125,6 +134,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.checkUpdates", "Check for Updates…", None, "{}"), ("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"), ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), + ("app.language", "Interface Language", None, "{language?: auto|en|de} → {language, current, available}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index f78e25267c..ccf48127a5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -88,7 +88,11 @@ fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, S .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)) + // Shown in the interface language; found by that name, the English one or the id. + .filter(|(id, label, _)| { + q.is_empty() || tl!(label).to_lowercase().contains(q) || label.to_lowercase().contains(q) || id.to_lowercase().contains(q) + }) + .map(|(id, label, sc)| (id, tl!(&label).to_string(), sc)) .collect(); items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(q)); items.truncate(60); @@ -106,7 +110,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); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 5bbd0a3543..8088951318 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -113,7 +113,7 @@ impl SlideApp { ui.weak(format!("Reading the file: {secs:.0} s")); ui.add_space(6.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - cancel = ui.button("Cancel").clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); }); }); if cancel || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { diff --git a/crates/ui-egui/src/panels/animation.rs b/crates/ui-egui/src/panels/animation.rs index 22d3c68ec6..1db6e4a61c 100644 --- a/crates/ui-egui/src/panels/animation.rs +++ b/crates/ui-egui/src/panels/animation.rs @@ -39,7 +39,7 @@ pub(super) fn animation(app: &mut SlideApp, ui: &mut Ui) { }); ui.separator(); if anims.is_empty() { - ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Select an object on the slide and add an animation from the Animations tab.")).color(t.text_dim)); return; } let bars = animtimeline::bars(anims.len(), &effects); @@ -152,17 +152,17 @@ pub(super) fn animation(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator(); - if ui.button("Move Up").clicked() { + if ui.button(tl!("Move Up")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); ui.close(); } - if ui.button("Move Down").clicked() { + if ui.button(tl!("Move Down")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.move", json!({"index": i, "to": i + 1})); ui.close(); } - if ui.button("Remove").clicked() { + if ui.button(tl!("Remove")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.remove", json!({"index": i})); ui.close(); diff --git a/crates/ui-egui/src/panels/background.rs b/crates/ui-egui/src/panels/background.rs index 906408a97c..e47eb91b1e 100644 --- a/crates/ui-egui/src/panels/background.rs +++ b/crates/ui-egui/src/panels/background.rs @@ -16,7 +16,7 @@ pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { }; // What "Apply to All" re-applies: the current slide's background as command params. let mut current: Option = None; - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Fill")).font(theme::bold(13.0))); for (l, k) in [ ("Solid fill", "solid"), ("Gradient fill", "gradient"), @@ -50,7 +50,7 @@ pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Solid { color }) => { current = Some(json!({"color": cref_param(color)})); ui.horizontal(|ui| { - ui.label("Color"); + ui.label(tl!("Color")); let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); widgets::color_popup(&r).show(|ui| { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { @@ -76,7 +76,7 @@ pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { } } Some(Fill::Picture(pf)) => { - if ui.button("Insert picture from file…").clicked() + if ui.button(tl!("Insert picture from file…")).clicked() && let Some(pick) = app.services.pick_open.as_mut() && let Some(path) = pick("picture") && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) @@ -90,13 +90,13 @@ pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(4.0); let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false); let mut h = hide; - if ui.checkbox(&mut h, "Hide background graphics").changed() { + if ui.checkbox(&mut h, tl!("Hide background graphics")).changed() { run(app, "design.hideBackgroundGraphics", json!({"hide": h})); } ui.add_space(8.0); let picture = matches!(fill, Some(Fill::Picture(_))); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if ui.add_enabled(current.is_some() || picture, egui::Button::new(tl!("Apply to All"))).clicked() { if let Some(Value::Object(mut o)) = current.clone() { o.insert("all".into(), json!(true)); run(app, "design.background", Value::Object(o)); @@ -104,7 +104,7 @@ pub(super) fn background(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.background", json!({"copy": true})); } } - if ui.button("Reset Background").clicked() { + if ui.button(tl!("Reset Background")).clicked() { run(app, "design.background", json!({"reset": true})); } }); @@ -120,7 +120,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); } let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); - if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + if ui.checkbox(&mut tile, tl!("Tile picture as texture")).changed() { run(app, "design.background", json!({"pictureProps": {"tile": tile}})); } let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; @@ -155,7 +155,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo _ => "Top Left", }; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new(tl!("Alignment"))); let combo = craft_ui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { for (k, l) in [ ("tl", "Top Left"), @@ -182,7 +182,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo _ => "None", }; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new(tl!("Mirror"))); let combo = craft_ui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { if ui.selectable_label(flip == k, l).clicked() { diff --git a/crates/ui-egui/src/panels/comments.rs b/crates/ui-egui/src/panels/comments.rs index edf0e8b841..6d4321536e 100644 --- a/crates/ui-egui/src/panels/comments.rs +++ b/crates/ui-egui/src/panels/comments.rs @@ -5,18 +5,18 @@ use super::{Align2, Color32, CornerRadius, Sense, SlideApp, Stroke, Tokens, Ui, pub(super) fn revisions(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Ok(v) = app.session.execute("review.revisions", &json!({})) else { - ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Compare this presentation with a revised copy from Review › Compare.")).color(t.text_dim)); return; }; let changes = v["changes"].as_array().cloned().unwrap_or_default(); ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); if v["byPosition"].as_bool() == Some(true) { - ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + ui.label(egui::RichText::new(tl!("Not a copy of this presentation: slides are matched by position.")).size(11.0).color(t.text_faint)); } ui.add_space(4.0); if changes.is_empty() { - ui.label("No differences left."); - if ui.button("End Review").clicked() { + ui.label(tl!("No differences left.")); + if ui.button(tl!("End Review")).clicked() { run(app, "review.endCompare", json!({})); app.ui.pane = None; } @@ -48,10 +48,10 @@ pub(super) fn revisions(app: &mut SlideApp, ui: &mut Ui) { run(app, "slide.go", json!({"index": (slide as usize).min(max)})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Reject").clicked() { + if ui.small_button(tl!("Reject")).clicked() { run(app, "review.reject", json!({"index": i})); } - if ui.small_button("Accept").clicked() { + if ui.small_button(tl!("Accept")).clicked() { run(app, "review.accept", json!({"index": i})); } }); @@ -66,7 +66,7 @@ pub(super) fn revisions(app: &mut SlideApp, ui: &mut Ui) { pub(super) fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - if ui.button("+ New Comment").clicked() { + if ui.button(tl!("+ New Comment")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("comment")); } ui.separator(); @@ -76,7 +76,7 @@ pub(super) fn comments(app: &mut SlideApp, ui: &mut Ui) { .map(|st| st.doc.slides.iter().enumerate().filter(|(_, sl)| !sl.comments.is_empty()).map(|(i, sl)| (i, sl.comments.clone())).collect()) .unwrap_or_default(); if threads.is_empty() { - ui.label(egui::RichText::new("No comments.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("No comments.")).color(t.text_dim)); return; } let focus = app.ui.comment_focus; @@ -133,7 +133,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: ui.horizontal(|ui| { let id = ui.scope_id().with(("reply", slide, index)); let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text(tl!("Reply…")).desired_width(ui.available_width() - 70.0)); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { focus_comment(app, slide, index); run(app, "comment.reply", json!({"index": index, "text": txt})); @@ -144,7 +144,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: focus_comment(app, slide, index); run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); } - if ui.small_button("🗑").clicked() { + if ui.small_button(tl!("🗑")).clicked() { let _ = app.run("slide.go", json!({"index": slide})); let _ = app.run("comment.delete", json!({"index": index})); match app.ui.comment_focus { diff --git a/crates/ui-egui/src/panels/designer.rs b/crates/ui-egui/src/panels/designer.rs index 16000b7c4d..1780cec6dc 100644 --- a/crates/ui-egui/src/panels/designer.rs +++ b/crates/ui-egui/src/panels/designer.rs @@ -13,7 +13,7 @@ struct IdeaThumbs { /// `design.applyIdea` or `design.theme`. pub(super) fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Pick a design for this slide. Ideas are generated on your device.")).color(t.text_dim)); ui.add_space(6.0); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -76,7 +76,7 @@ pub(super) fn designer(app: &mut SlideApp, ui: &mut Ui) { clicked }; if ideas.is_empty() { - ui.label(egui::RichText::new("Add a title with a picture or some text to get layout ideas.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Add a title with a picture or some text to get layout ideas.")).color(t.text_dim)); } for (id, label) in &ideas { if thumb(ui, &format!("layout:{id}"), label) { @@ -84,7 +84,7 @@ pub(super) fn designer(app: &mut SlideApp, ui: &mut Ui) { } } ui.add_space(4.0); - ui.label(egui::RichText::new("Themes (whole presentation)").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Themes (whole presentation)")).color(t.text_dim)); ui.add_space(4.0); for th in &themes { if thumb(ui, &format!("theme:{}", th.name), &th.name) { diff --git a/crates/ui-egui/src/panels/fillui.rs b/crates/ui-egui/src/panels/fillui.rs index 5150b8f0ea..2adcb4b57c 100644 --- a/crates/ui-egui/src/panels/fillui.rs +++ b/crates/ui-egui/src/panels/fillui.rs @@ -111,7 +111,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)).min(g.stops.len().saturating_sub(1)); // Preset variations of the first stop's colour. - ui.label("Preset gradients"); + ui.label(tl!("Preset gradients")); ui.horizontal_wrapped(|ui| { let base = g.stops.first().map(|s| s.color.clone()).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)); let mut light = base.clone(); @@ -168,7 +168,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> GradientShape::Path { .. } => "Radial", }; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); + let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new(tl!("Type"))); let combo = craft_ui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { for k in ["Linear", "Radial", "Rectangular", "Path"] { if ui.selectable_label(kind == k, k).clicked() && kind != k { @@ -189,7 +189,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> let _ = combo.response.labelled_by(field.id); }); ui.horizontal_wrapped(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(tl!("Direction"))); match &g.shape { GradientShape::Linear { angle, .. } => { for (l, a) in DIRECTIONS_LINEAR { @@ -211,7 +211,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> }); if let GradientShape::Linear { angle, .. } = &g.shape { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Angle")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(tl!("Angle"))); if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), "Angle", *angle, 15.0, "°", 70.0) { out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() }); } @@ -220,7 +220,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // Stop bar: click to add, drag a stop to move it. ui.add_space(4.0); - ui.label("Gradient stops"); + ui.label(tl!("Gradient stops")); let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click()); bar_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Gradient bar")); paint_bar(ui, bar, g, sc); @@ -278,7 +278,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // The selected stop. if let Some(s) = g.stops.get(sel) { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Color")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(tl!("Color"))); let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true); @@ -290,7 +290,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); let can_remove = g.stops.len() > 2; - if ui.add_enabled(can_remove, egui::Button::new("Remove stop")).clicked() { + if ui.add_enabled(can_remove, egui::Button::new(tl!("Remove stop"))).clicked() { let mut ng = g.clone(); ng.stops.remove(sel); sel = sel.saturating_sub(1); @@ -298,7 +298,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Position")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(tl!("Position"))); if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), "Position", (s.pos * 100.0).round(), 5.0, "%", 70.0) { let mut ng = g.clone(); ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0); @@ -307,7 +307,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); + let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new(tl!("Transparency"))); let key = ui.scope_id().with((id, "tr", sel)); let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32)); let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")).labelled_by(label.id); @@ -474,7 +474,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option let t = Tokens::get(ui.ctx()); let mut out: Option = None; let (fg, bg) = (c32(&pt.fg, sc), c32(&pt.bg, sc)); - ui.label("Pattern"); + ui.label(tl!("Pattern")); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); for name in pattern_gallery() { diff --git a/crates/ui-egui/src/panels/format_shape.rs b/crates/ui-egui/src/panels/format_shape.rs index 8a4d2377d9..c392a60651 100644 --- a/crates/ui-egui/src/panels/format_shape.rs +++ b/crates/ui-egui/src/panels/format_shape.rs @@ -46,7 +46,7 @@ fn format_tabs(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { } fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { - ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Shadow")).font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { for (l, v) in [("None", "none"), ("Outer", "outer"), ("Bottom", "bottom"), ("Center", "center"), ("Inner", "inner"), ("Perspective", "perspective")] @@ -77,7 +77,7 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); } ui.add_space(6.0); - ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Reflection")).font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { if ui.button(l).clicked() { @@ -103,7 +103,7 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); } ui.add_space(6.0); - ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Glow")).font(theme::bold(13.0))); let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); if let Some(g) = &fx.glow { @@ -121,14 +121,14 @@ fn effects_tab(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { 0.01, ); } - ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Soft Edges")).font(theme::bold(13.0))); let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { run(app, "shape.effects", json!({"softEdges": s})); } let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); three_d_options(app, ui, sh.id.0, raw3d.as_deref()); - if ui.button("Reset Effects").clicked() { + if ui.button(tl!("Reset Effects")).clicked() { run(app, "shape.effects", json!({"reset": true})); } } @@ -138,7 +138,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { - ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Select a shape to format it.")).color(t.text_dim)); return; }; let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); @@ -165,39 +165,39 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } }); }; - ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Size")).font(theme::bold(13.0))); fields(ui, "Height", "fh", x.h, "h", "shape.resize", false); fields(ui, "Width", "fw", x.w, "w", "shape.resize", false); fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true); - if ui.checkbox(&mut lock, "Lock aspect ratio").changed() { + if ui.checkbox(&mut lock, tl!("Lock aspect ratio")).changed() { ui.data_mut(|d| d.insert_temp(lock_id, lock)); } ui.add_space(6.0); - ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Position")).font(theme::bold(13.0))); fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false); fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false); ui.add_space(6.0); - ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Alt Text")).font(theme::bold(13.0))); let id = ui.scope_id().with(("alt", uid, sh.id.0)); let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); let mut alt = widgets::text_draft(stored, &sh.descr); - let r = ui.add(egui::TextEdit::multiline(&mut alt).hint_text("Alt text").desired_rows(3).desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::multiline(&mut alt).hint_text(tl!("Alt text")).desired_rows(3).desired_width(f32::INFINITY)); if r.lost_focus() && alt != sh.descr { run(app, "shape.altText", json!({"text": alt})); } ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); } "text" => { - ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Text Fill")).font(theme::bold(13.0))); color_button(app, ui, "Color", None, |c| match c { Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), None => ("format.color".into(), json!({"color": "tx1"})), }); text_transparency_slider(app, ui, &sh); ui.add_space(6.0); - ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Text Box")).font(theme::bold(13.0))); ui.horizontal(|ui| { - ui.label("Vertical alignment"); + ui.label(tl!("Vertical alignment")); for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { if ui.small_button(l).clicked() { run(app, "format.anchor", json!({"anchor": a})); @@ -213,10 +213,10 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true); let mut w = wrap; - if ui.checkbox(&mut w, "Wrap text in shape").changed() { + if ui.checkbox(&mut w, tl!("Wrap text in shape")).changed() { run(app, "format.wrap", json!({"on": w})); } - ui.label("Margins (pt)"); + ui.label(tl!("Margins (pt)")); let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); for (l, k, v) in [ ("Left", "left", b.inset_l.unwrap_or(7.2)), @@ -232,13 +232,13 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); } ui.horizontal(|ui| { - ui.label("Columns"); + ui.label(tl!("Columns")); for n in 1..=3 { if ui.small_button(n.to_string()).clicked() { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } - if ui.small_button("Columns…").clicked() { + if ui.small_button(tl!("Columns…")).clicked() { run(app, "format.columns", json!({})); } }); @@ -262,13 +262,13 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text("Transparency")).drag_stopped() { run(app, "picture.adjust", json!({"transparency": tr})); } - if ui.button("Reset Picture").clicked() { + if ui.button(tl!("Reset Picture")).clicked() { run(app, "picture.reset", json!({})); } } _ => { // Fill & Line. - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Fill")).font(theme::bold(13.0))); let kind = match &sh.fill { None => "auto", Some(Fill::None) => "none", @@ -317,7 +317,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { if !transparency { return; } - let label = ui.label("Transparency"); + let label = ui.label(tl!("Transparency")); // Seed from this shape. A shared temp defaulted to 0, so a shape that was // already 40% transparent showed as opaque, and leaving the slider wrote 0. let seed = match &sh.fill { @@ -345,7 +345,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } let mut rot = g.rotate_with_shape; - if ui.checkbox(&mut rot, "Rotate with shape").changed() { + if ui.checkbox(&mut rot, tl!("Rotate with shape")).changed() { let ng = deckcraft_model::style::Gradient { rotate_with_shape: rot, ..g.clone() }; run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } @@ -356,7 +356,7 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)})); } ui.add_space(8.0); - ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("Line")).font(theme::bold(13.0))); let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); if ui.radio(none, "No line").clicked() { run(app, "shape.line", json!({"none": true})); @@ -372,14 +372,14 @@ pub(super) fn format_shape(app: &mut SlideApp, ui: &mut Ui) { None => ("shape.line".into(), json!({"none": true})), }); ui.horizontal(|ui| { - ui.label("Width"); + ui.label(tl!("Width")); let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75); if let Some(v) = widgets::spinner(ui, "lw", "Width", w, 0.25, " pt", 60.0) { run(app, "shape.line", json!({"width": v.max(0.0)})); } }); ui.horizontal_wrapped(|ui| { - ui.label("Dash"); + ui.label(tl!("Dash")); for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] { if ui.small_button(l).clicked() { run(app, "shape.line", json!({"dash": d})); @@ -512,13 +512,13 @@ fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &s fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { use deckcraft_model::style::PictureMode; ui.horizontal(|ui| { - ui.label("Picture source"); - if ui.button("Insert…").clicked() { + ui.label(tl!("Picture source")); + if ui.button(tl!("Insert…")).clicked() { pick_fill_picture(app); } }); let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); - if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + if ui.checkbox(&mut tile, tl!("Tile picture as texture")).changed() { run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); } match &pf.mode { @@ -570,7 +570,7 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft } } let mut rot = !pf.upright; - if ui.checkbox(&mut rot, "Rotate with shape").changed() { + if ui.checkbox(&mut rot, tl!("Rotate with shape")).changed() { run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); } } @@ -624,7 +624,7 @@ fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) let s3 = raw.map(Shape3d::parse).unwrap_or_default(); let with_none: Vec<&str> = std::iter::once("none").chain(BEVELS.iter().copied()).collect(); ui.add_space(6.0); - ui.label(egui::RichText::new("3-D Format").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("3-D Format")).font(theme::bold(13.0))); for (label, key, b) in [("Top bevel", "bevelTop", &s3.top), ("Bottom bevel", "bevelBottom", &s3.bottom)] { three_d_combo(app, ui, id, label, key, b.as_ref().map_or("none", |b| b.preset.as_str()), &with_none); if let Some(b) = b { @@ -637,14 +637,14 @@ fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) three_d_combo(app, ui, id, "Material", "material", &s3.material, MATERIALS); three_d_combo(app, ui, id, "Lighting", "lighting", &s3.light, LIGHTS); ui.add_space(6.0); - ui.label(egui::RichText::new("3-D Rotation").font(theme::bold(13.0))); + ui.label(egui::RichText::new(tl!("3-D Rotation")).font(theme::bold(13.0))); three_d_combo(app, ui, id, "Presets", "camera", &s3.camera, CAMERAS); let (rx, ry, rz) = s3.rot.unwrap_or((0.0, 0.0, 0.0)); three_d_drag(app, ui, "X Rotation", rx, "°", "rotX"); three_d_drag(app, ui, "Y Rotation", ry, "°", "rotY"); three_d_drag(app, ui, "Z Rotation", rz, "°", "rotZ"); three_d_drag(app, ui, "Distance from ground", s3.z, " pt", "z"); - if ui.add_enabled(raw.is_some(), egui::Button::new("Reset 3-D")).clicked() { + if ui.add_enabled(raw.is_some(), egui::Button::new(tl!("Reset 3-D"))).clicked() { run(app, "shape.effects", json!({"threeD": {"reset": true}})); } } @@ -662,7 +662,7 @@ pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { /// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { ui.horizontal(|ui| { - let label = ui.label("Transparency"); + let label = ui.label(tl!("Transparency")); let id = ui.scope_id().with(("text-transparency", sh.id)); let stored = (text_transparency_of(sh) * 100.0) as f32; let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); diff --git a/crates/ui-egui/src/panels/selpane.rs b/crates/ui-egui/src/panels/selpane.rs index b9325548da..1f0ebf2340 100644 --- a/crates/ui-egui/src/panels/selpane.rs +++ b/crates/ui-egui/src/panels/selpane.rs @@ -120,10 +120,10 @@ fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egu fn toolbar(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, has_sel: bool) { ui.horizontal(|ui| { - if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { + if ui.add(egui::Button::new(tl!("Show All")).stroke(Stroke::new(1.0, t.border))).clicked() { run(app, "selection.showAll", json!({})); } - if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() { + if ui.add(egui::Button::new(tl!("Hide All")).stroke(Stroke::new(1.0, t.border))).clicked() { run(app, "selection.hideAll", json!({})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -205,8 +205,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let renaming = app.selpane.renaming.as_ref().filter(|(id, _)| *id == row.id).map(|(_, s)| s.clone()); if let Some(mut buf) = renaming { let edit_id = ui.scope_id().with(("rename", row.id)); - let out = - ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text("Name").font(theme::font(12.5)).margin(vec2(2.0, 1.0))); + let out = ui.put( + name_rect, + egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text(tl!("Name")).font(theme::font(12.5)).margin(vec2(2.0, 1.0)), + ); if !app.selpane.rename_started { out.request_focus(); if let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), edit_id) { @@ -398,7 +400,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { } } ui.separator(); - if ui.button("Rename").clicked() { + if ui.button(tl!("Rename")).clicked() { app.selpane.renaming = Some((row.id, row.name.clone())); app.selpane.rename_started = false; ui.close(); diff --git a/crates/ui-egui/src/panels/smartart.rs b/crates/ui-egui/src/panels/smartart.rs index aa872d1f06..53b6eece92 100644 --- a/crates/ui-egui/src/panels/smartart.rs +++ b/crates/ui-egui/src/panels/smartart.rs @@ -4,7 +4,7 @@ use super::{SlideApp, Tokens, Ui, Value, json, run}; /// Shift+Tab demote and promote, Backspace in an empty line removes it. pub(super) fn smartart(app: &mut SlideApp, ui: &mut Ui) { let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { - ui.label("Select a SmartArt graphic to edit its text."); + ui.label(tl!("Select a SmartArt graphic to edit its text.")); return; }; let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); @@ -16,15 +16,15 @@ pub(super) fn smartart(app: &mut SlideApp, ui: &mut Ui) { let mut action: Option<(&str, Value)> = None; let mut commit: Option = None; let mut focus_row: Option = None; - ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(tl!("Type your text here")).color(Tokens::get(ui.ctx()).text_dim)); ui.add_space(4.0); for (i, node) in d.nodes.iter().enumerate() { let Some(draft) = drafts.get_mut(i) else { break }; let id = egui::Id::unique(("smartart_line", gid.0, i)); ui.horizontal(|ui| { ui.add_space(16.0 * node.level as f32); - ui.label("•"); - let te = egui::TextEdit::singleline(draft).id(id).hint_text("SmartArt text").desired_width(f32::INFINITY); + ui.label(tl!("•")); + let te = egui::TextEdit::singleline(draft).id(id).hint_text(tl!("SmartArt text")).desired_width(f32::INFINITY); let r = ui.add(te); if app.ui.smartart_focus == Some(i) { r.request_focus(); @@ -55,10 +55,10 @@ pub(super) fn smartart(app: &mut SlideApp, ui: &mut Ui) { } ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("+ Add").clicked() { + if ui.button(tl!("+ Add")).clicked() { action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); } - if ui.button("Remove").clicked() && !d.nodes.is_empty() { + if ui.button(tl!("Remove")).clicked() && !d.nodes.is_empty() { action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); } }); diff --git a/crates/ui-egui/src/panels/thesaurus.rs b/crates/ui-egui/src/panels/thesaurus.rs index ca4e5133b9..a89720b3bd 100644 --- a/crates/ui-egui/src/panels/thesaurus.rs +++ b/crates/ui-egui/src/panels/thesaurus.rs @@ -5,14 +5,14 @@ use super::{SlideApp, Tokens, Ui, json, run, theme}; pub(super) fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let mut q = app.thesaurus_query.clone(); - let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search for a word")).desired_width(f32::INFINITY)); if edit.changed() { app.thesaurus_query = q.clone(); } ui.add_space(6.0); let senses = deckcraft_engine::thesaurus::lookup(&q); if q.trim().is_empty() { - ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + ui.label(egui::RichText::new(tl!("Select a word on the slide, or type one above.")).color(t.text_dim)); return; } if senses.is_empty() { diff --git a/crates/ui-egui/src/panels/thumbs.rs b/crates/ui-egui/src/panels/thumbs.rs index 25be3e8c2d..7335c30113 100644 --- a/crates/ui-egui/src/panels/thumbs.rs +++ b/crates/ui-egui/src/panels/thumbs.rs @@ -83,25 +83,25 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { t.text_dim, ); resp.context_menu(|ui| { - if ui.button("Rename Section…").clicked() { + if ui.button(tl!("Rename Section…")).clicked() { let mut d = crate::dialogs::Dialog::new("renameSection"); d.params = json!({"index": si, "name": sec.name}); app.dialog = Some(d); ui.close(); } - if ui.button("Remove Section").clicked() { + if ui.button(tl!("Remove Section")).clicked() { let _ = app.run("section.remove", json!({"index": si})); ui.close(); } - if ui.button("Remove Section & Slides").clicked() { + if ui.button(tl!("Remove Section & Slides")).clicked() { let _ = app.run("section.remove", json!({"index": si, "slides": true})); ui.close(); } - if ui.button("Move Section Up").clicked() { + if ui.button(tl!("Move Section Up")).clicked() { let _ = app.run("section.move", json!({"index": si, "to": si.saturating_sub(1)})); ui.close(); } - if ui.button("Move Section Down").clicked() { + if ui.button(tl!("Move Section Down")).clicked() { let _ = app.run("section.move", json!({"index": si, "to": si + 1})); ui.close(); } @@ -235,20 +235,20 @@ pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) { } } ui.separator(); - if ui.button("New Slide").clicked() { + if ui.button(tl!("New Slide")).clicked() { let _ = app.run("slide.new", json!({})); ui.close(); } - if ui.button("Duplicate Slide").clicked() { + if ui.button(tl!("Duplicate Slide")).clicked() { let _ = app.run("slide.duplicate", json!({})); ui.close(); } - if ui.button("Delete Slide").clicked() { + if ui.button(tl!("Delete Slide")).clicked() { let _ = app.run("slide.delete", json!({})); ui.close(); } ui.separator(); - if ui.button("Add Section").clicked() { + if ui.button(tl!("Add Section")).clicked() { let _ = app.run("section.add", json!({"at": i})); ui.close(); } @@ -265,11 +265,11 @@ pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) { } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(tl!("Reset Slide")).clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); } - if ui.button("Format Background…").clicked() { + if ui.button(tl!("Format Background…")).clicked() { let _ = app.run("view.pane", json!({"pane": "background"})); ui.close(); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 51c46b6c72..3b5bd5b7fd 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -134,33 +134,35 @@ 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 w = ui.fonts_mut(|f| f.layout_no_wrap(tl!("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, tl!("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, tl!("File"), theme::font(13.0), t.text); + if resp.on_hover_text(format!("{} ({})", tl!("File"), pretty_shortcut("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 { + // `name` is the tab's id (`app.ui.tab`, `view.tab`); only its caption is translated. + let caption = 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(caption.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)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, caption)); let active = app.ui.tab == name; if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); } 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, caption, 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); @@ -246,13 +248,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let mut next = None; if offset > 1.0 { let left = Rect::from_min_max(pos2((r.left() - edge).max(inner.left()), r.top()), pos2(r.left(), r.bottom())); - if edge_cue(&mut cui, left, "‹", "Scroll ribbon left", &t) { + if edge_cue(&mut cui, left, "‹", tl!("Scroll ribbon left"), &t) { next = Some(offset - step); } } if overflow - offset > 1.0 { let right = Rect::from_min_max(pos2(r.right(), r.top()), pos2((r.right() + edge).min(inner.right()), r.bottom())); - if edge_cue(&mut cui, right, "›", "Scroll ribbon right", &t) { + if edge_cue(&mut cui, right, "›", tl!("Scroll ribbon right"), &t) { next = Some(offset + step); } } @@ -306,6 +308,7 @@ fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on } fn tip(id: &str, label: &str) -> String { + let label = tl!(label); let sc = deckcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.2)); match sc { Some(s) => format!("{label} ({})", pretty_shortcut(s)), diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs index 30dbb9e3fa..e6da94e236 100644 --- a/crates/ui-egui/src/ribbon/animations.rs +++ b/crates/ui-egui/src/ribbon/animations.rs @@ -17,7 +17,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!({})); } }); @@ -125,7 +125,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)> = @@ -139,7 +139,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() { @@ -153,7 +153,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", @@ -169,7 +169,7 @@ 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) { @@ -177,7 +177,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) { } }); 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) { diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs index 0d340df4ec..7d1ae29440 100644 --- a/crates/ui-egui/src/ribbon/arrange.rs +++ b/crates/ui-egui/src/ribbon/arrange.rs @@ -3,7 +3,7 @@ use super::{ }; pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); + ui.label(egui::RichText::new(tl!("Order Objects")).font(theme::bold(12.0))); for (l, id, ic) in [ ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), ("Send to Back", "arrange.sendToBack", Icon::SendToBack), @@ -13,12 +13,12 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { menu_item(app, ui, ic, l, id, json!({})); } ui.separator(); - ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); + ui.label(egui::RichText::new(tl!("Group Objects")).font(theme::bold(12.0))); menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); 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.label(egui::RichText::new(tl!("Position Objects")).font(theme::bold(12.0))); ui.menu_button("Align", |ui| align_menu(app, ui)); ui.menu_button("Rotate", |ui| { menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); @@ -26,7 +26,7 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { 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(); } @@ -132,8 +132,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; @@ -141,19 +141,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:"); + ui.label(tl!("Height:")); if let Some(v) = widgets::spinner(ui, "sh", "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: "); + ui.label(tl!("Width: ")); if let Some(v) = widgets::spinner(ui, "sw", "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!({})); } }); diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs index 7a81c98e36..f7844d6211 100644 --- a/crates/ui-egui/src/ribbon/design.rs +++ b/crates/ui-egui/src/ribbon/design.rs @@ -78,7 +78,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(); } @@ -92,7 +92,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(); } @@ -135,7 +135,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})); } }); @@ -143,13 +143,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")); } }); diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs index 592390e16e..49a7a74cbb 100644 --- a/crates/ui-egui/src/ribbon/draw.rs +++ b/crates/ui-egui/src/ribbon/draw.rs @@ -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)")); }); } diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs index 41291062fd..5bbca862f4 100644 --- a/crates/ui-egui/src/ribbon/galleries.rs +++ b/crates/ui-egui/src/ribbon/galleries.rs @@ -296,7 +296,7 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { } }); ui.menu_button("Glow", |ui| { - if ui.button("No Glow").clicked() { + 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] { @@ -333,7 +333,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"})); } } diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs index 1c27dc9834..1dc223082c 100644 --- a/crates/ui-egui/src/ribbon/home.rs +++ b/crates/ui-egui/src/ribbon/home.rs @@ -10,21 +10,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!({})); } }); @@ -34,7 +34,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() { @@ -100,7 +100,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 { @@ -124,10 +124,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(); @@ -136,16 +136,16 @@ fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { if r.clicked() { run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); } - r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + r.on_disabled_hover_text(tl!("Choosing a presentation to reuse needs the desktop app")); } } /// 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!({})); } } @@ -190,7 +190,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(); } @@ -259,7 +259,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!({})); } }); @@ -276,12 +276,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { 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() { + 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 ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { @@ -290,7 +290,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(), "bulleted".into()); app.dialog = Some(dlg); @@ -298,12 +298,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { }); } let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); - if m.on_hover_text("Numbering").clicked() { + 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 [ @@ -320,7 +320,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); @@ -336,10 +336,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")); } }); @@ -350,7 +350,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } - if ui.button("More Columns…").clicked() { + if ui.button(tl!("More Columns…")).clicked() { run(app, "format.columns", json!({})); } }); @@ -386,7 +386,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!({})); } }); @@ -398,7 +398,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() }); diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index fab6e3174e..32689f9f17 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -13,7 +13,7 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); if let Some(a) = &arrow { - let _ = a.clone().on_hover_text("Photo Album options"); + let _ = a.clone().on_hover_text(tl!("Photo Album options")); a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); } if main.clicked() { @@ -47,11 +47,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(); } @@ -61,7 +61,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")); @@ -100,7 +100,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")); } }); @@ -191,7 +191,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.")); } } @@ -201,7 +201,7 @@ fn icon_library(app: &mut SlideApp, ui: &mut Ui) { let (color, hover) = (t.text, t.hover); let id = ui.scope_id().with("icon-query"); let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); - ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); + ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search icons")).desired_width(ui.available_width())); let q = query.trim().to_lowercase(); ui.data_mut(|d| d.insert_temp(id, query)); let icons = deckcraft_engine::icons::icons(); diff --git a/crates/ui-egui/src/ribbon/masters.rs b/crates/ui-egui/src/ribbon/masters.rs index ac33b352ea..12c82fa586 100644 --- a/crates/ui-egui/src/ribbon/masters.rs +++ b/crates/ui-egui/src/ribbon/masters.rs @@ -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!({})); } }); diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs index e2704f5a3a..cb7ed3c687 100644 --- a/crates/ui-egui/src/ribbon/picture_format.rs +++ b/crates/ui-egui/src/ribbon/picture_format.rs @@ -39,7 +39,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 = presets.iter().map(|(_, l)| l.clone()).collect(); @@ -61,14 +61,14 @@ 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() { + if ui.button(tl!("Set Transparent Color")).on_hover_text(tl!("Click a colour in the picture to make it transparent")).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 { @@ -148,7 +148,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"})); @@ -157,7 +157,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})); } @@ -248,11 +248,11 @@ fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::Col 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.label(egui::RichText::new(tl!("Compression options")).strong()); + 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()); + ui.label(egui::RichText::new(tl!("Resolution")).strong()); for (v, label) in [ (330, "HD (330 ppi): good quality for high-definition displays"), (220, "Print (220 ppi): excellent quality on most printers and screens"), @@ -263,7 +263,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) => { diff --git a/crates/ui-egui/src/ribbon/playback.rs b/crates/ui-egui/src/ribbon/playback.rs index 1b76f13bae..f040aa6b18 100644 --- a/crates/ui-egui/src/ribbon/playback.rs +++ b/crates/ui-egui/src/ribbon/playback.rs @@ -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})); } }); diff --git a/crates/ui-egui/src/ribbon/qat.rs b/crates/ui-egui/src/ribbon/qat.rs index a0b77dbd7f..e0fbc3f36c 100644 --- a/crates/ui-egui/src/ribbon/qat.rs +++ b/crates/ui-egui/src/ribbon/qat.rs @@ -125,7 +125,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,7 +133,7 @@ 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() { @@ -152,8 +152,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } } 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(); } diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs index 7c5db24c63..4e45531e80 100644 --- a/crates/ui-egui/src/ribbon/review.rs +++ b/crates/ui-egui/src/ribbon/review.rs @@ -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| { diff --git a/crates/ui-egui/src/ribbon/shape_format.rs b/crates/ui-egui/src/ribbon/shape_format.rs index 1f648bd1cf..ffc287cfff 100644 --- a/crates/ui-egui/src/ribbon/shape_format.rs +++ b/crates/ui-egui/src/ribbon/shape_format.rs @@ -10,8 +10,8 @@ use super::{ /// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. 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(egui::RichText::new(tl!("Transform")).font(theme::bold(12.0))); + ui.label(tl!("Follow Path")); let mut picked = false; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); @@ -22,7 +22,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); @@ -120,12 +120,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(); } @@ -133,7 +133,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() { @@ -163,7 +163,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(); } @@ -207,7 +207,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(); } @@ -253,10 +253,10 @@ 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") }; + let resp = if usable { resp } else { resp.on_disabled_hover_text(tl!("For rectangles and rounded rectangles")) }; if resp.changed() { state.1 = pct; ui.data_mut(|d| d.insert_temp(key, state)); diff --git a/crates/ui-egui/src/ribbon/slide_show.rs b/crates/ui-egui/src/ribbon/slide_show.rs index 89c78c11b0..1ab7895dd7 100644 --- a/crates/ui-egui/src/ribbon/slide_show.rs +++ b/crates/ui-egui/src/ribbon/slide_show.rs @@ -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() { diff --git a/crates/ui-egui/src/ribbon/smartart.rs b/crates/ui-egui/src/ribbon/smartart.rs index 969cc61466..4d98c6d132 100644 --- a/crates/ui-egui/src/ribbon/smartart.rs +++ b/crates/ui-egui/src/ribbon/smartart.rs @@ -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() { diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs index 8c49c354de..f827edc370 100644 --- a/crates/ui-egui/src/ribbon/table.rs +++ b/crates/ui-egui/src/ribbon/table.rs @@ -15,31 +15,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})); } }); diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs index 6c7e825e8c..5241ba00ef 100644 --- a/crates/ui-egui/src/ribbon/title_bar.rs +++ b/crates/ui-egui/src/ribbon/title_bar.rs @@ -55,7 +55,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { let dirty = app.session.active().is_some_and(|d| d.is_dirty()); crate::qat::show(app, &mut child); let title = - app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); + app.session.active().map(|d| format!("{}{}", d.title(), if dirty { tl!(" — Edited") } else { "" })).unwrap_or_else(|| "DeckCraft".into()); ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); // Right side. let mut right = ui.new_child( @@ -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)), diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs index 3f43ebd98c..9636283e60 100644 --- a/crates/ui-egui/src/ribbon/transitions.rs +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -72,7 +72,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() @@ -96,11 +96,11 @@ 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(); } - r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + r.on_disabled_hover_text(tl!("Choosing a sound file needs the desktop app")); ui.separator(); let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); @@ -113,7 +113,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})); @@ -121,14 +121,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) { diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs index c67f1c80e9..e0b8300c0b 100644 --- a/crates/ui-egui/src/ribbon/view.rs +++ b/crates/ui-egui/src/ribbon/view.rs @@ -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 { diff --git a/crates/ui-egui/src/show/presenter.rs b/crates/ui-egui/src/show/presenter.rs index 4b0c2529ab..f87fc16a4d 100644 --- a/crates/ui-egui/src/show/presenter.rs +++ b/crates/ui-egui/src/show/presenter.rs @@ -218,12 +218,12 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.0)); let mut head = ui.new_child(egui::UiBuilder::new().max_rect(notes_head)); head.horizontal(|ui| { - ui.label(egui::RichText::new("Notes").size(13.0).color(Color32::from_gray(170))); - if ui.button("A-").clicked() { + ui.label(egui::RichText::new(tl!("Notes")).size(13.0).color(Color32::from_gray(170))); + if ui.button(tl!("A-")).clicked() { show.nudge_notes(-NOTES_STEP); } ui.label(egui::RichText::new(format!("{}", show.notes_px as i32)).size(13.0).color(Color32::from_gray(200))); - if ui.button("A+").clicked() { + if ui.button(tl!("A+")).clicked() { show.nudge_notes(NOTES_STEP); } }); @@ -251,7 +251,7 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese pub(super) fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32) -> egui::Response { let color = Color32::from_gray(230); if notes.text().is_empty() { - ui.label(egui::RichText::new("No notes.").size(px).color(color)) + ui.label(egui::RichText::new(tl!("No notes.")).size(px).color(color)) } else { crate::notes::view(ui, notes, &crate::notes::Look { size: px, color: Some(color) }) } diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 7f850facb4..4b9aa81ffb 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -630,16 +630,16 @@ fn draw_bars(ui: &mut Ui, show: &mut Show, f: &Fr, painter: &egui::Painter, out: .max_rect(Rect::from_min_max(pos2(bar.max.x - 260.0, bar.min.y), bar.max)) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - if child.button("Close").clicked() { + if child.button(tl!("Close")).clicked() { out.keep = false; } - if child.button("▶").clicked() { + if child.button(tl!("▶")).clicked() { let a = show.state.next(doc); if !show.apply(a, doc, now) { out.keep = false; } } - if child.button("◀").clicked() { + if child.button(tl!("◀")).clicked() { let a = show.state.prev(doc); show.apply(a, doc, now); } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index b370618606..6099e82d3d 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -99,7 +99,7 @@ fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { if let Some(mut buf) = renaming { let edit_id = ui.scope_id().with(("sorter-rename", h.uid, h.index)); let out = - ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text("Name").font(theme::bold(13.0)).margin(vec2(4.0, 2.0))); + ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(edit_id).hint_text(tl!("Name")).font(theme::bold(13.0)).margin(vec2(4.0, 2.0))); let already = app.sorter.rename_started; if !already { out.request_focus(); @@ -147,31 +147,31 @@ fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { let index = h.index; let name = h.name.to_string(); resp.context_menu(|ui| { - if ui.button("Rename Section…").clicked() { + if ui.button(tl!("Rename Section…")).clicked() { let mut d = crate::dialogs::Dialog::new("renameSection"); d.params = json!({"index": index, "name": name}); app.dialog = Some(d); ui.close(); } - if ui.button("Add Section").clicked() { + if ui.button(tl!("Add Section")).clicked() { if let Some(at) = app.session.active().map(|st| st.selection.slide) { let _ = app.run("section.add", json!({"at": at})); } ui.close(); } - if ui.button("Remove Section").clicked() { + if ui.button(tl!("Remove Section")).clicked() { let _ = app.run("section.remove", json!({"index": index})); ui.close(); } - if ui.button("Remove Section & Slides").clicked() { + if ui.button(tl!("Remove Section & Slides")).clicked() { let _ = app.run("section.remove", json!({"index": index, "slides": true})); ui.close(); } - if ui.button("Move Section Up").clicked() { + if ui.button(tl!("Move Section Up")).clicked() { let _ = app.run("section.move", json!({"index": index, "to": index.saturating_sub(1)})); ui.close(); } - if ui.button("Move Section Down").clicked() { + if ui.button(tl!("Move Section Down")).clicked() { let _ = app.run("section.move", json!({"index": index, "to": index + 1})); ui.close(); } @@ -281,7 +281,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(12.0); ui.horizontal(|ui| { ui.add_space(24.0); - if ui.add(egui::Button::new("Add Section").stroke(Stroke::new(1.0, t.border))).clicked() { + if ui.add(egui::Button::new(tl!("Add Section")).stroke(Stroke::new(1.0, t.border))).clicked() { let _ = app.run("section.add", json!({"at": cur})); } }); @@ -422,7 +422,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) }; let width = notes_rect.width(); let r = crate::notes::editor(&mut child, &mut draft, &look, |e| { - e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE) + e.id(edit_id).hint_text(tl!("Click to add text")).desired_width(width).desired_rows(12).frame(egui::Frame::NONE) }) .response .response; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 743d11966e..7f04b49c10 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -91,6 +91,8 @@ pub struct UiState { pub comment_focus: Option<(usize, usize)>, /// Quick Access Toolbar buttons, left to right. pub qat: Vec, + /// Interface language: [`crate::i18n::AUTO`] (follow the system) or a language code (`de`). + pub language: String, } impl Default for UiState { @@ -123,6 +125,7 @@ impl Default for UiState { grayscale: false, comment_focus: None, qat: qat::defaults(), + language: crate::i18n::AUTO.into(), } } } @@ -152,6 +155,8 @@ impl UiState { self.format_tab = d.format_tab; } self.pane = self.pane.filter(short); + // A code this build doesn't offer (a newer version's, a hand edit) follows the system. + self.language = crate::i18n::normalize_pref(&self.language).unwrap_or(crate::i18n::AUTO).into(); self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); self.recent.dedup(); self.recent.truncate(RECENT_MAX); diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 0301af446b..9c9396f418 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -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 => crate::i18n::fmt( + tl!("Slide {n} of {total}"), + &[("n", &(st.selection.slide + 1).min(n.max(1)).to_string()), ("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")); } @@ -60,7 +63,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { } let pct = (app.canvas_scale * 100.0).round(); let lbl = right.add(egui::Label::new(egui::RichText::new(format!("{pct}%")).size(12.0).color(t.text_dim)).sense(Sense::click())); - lbl.widget_info(|| named_with_value("Zoom level", &format!("{pct}%"))); + lbl.widget_info(|| named_with_value(tl!("Zoom level"), &format!("{pct}%"))); if lbl.clicked() { let _ = app.run("view.zoomDialog", json!({})); } @@ -79,7 +82,10 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { // technology only, leaving its geometry unchanged. Its value is the percentage shown. let shown = format!("{}%", zp.round()); let enabled = s.enabled(); - s.widget_info(|| egui::WidgetInfo { current_text_value: Some(shown.clone()), ..egui::WidgetInfo::slider(enabled, f64::from(zp), "Zoom") }); + s.widget_info(|| egui::WidgetInfo { + current_text_value: Some(shown.clone()), + ..egui::WidgetInfo::slider(enabled, f64::from(zp), tl!("Zoom")) + }); if s.changed() { z = zp / 100.0; app.ui.zoom = Some(z); @@ -117,6 +123,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { /// Draw a small status icon with a tooltip and return its interaction response. fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let tip = tl!(tip); let resp = icon_button(ui, icon, on); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); resp.on_hover_text(tip) @@ -124,6 +131,7 @@ fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { /// A view button: screen readers hear its name and whether its view is the one shown. fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let tip = tl!(tip); let resp = icon_button(ui, icon, on); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip)); resp.on_hover_text(tip) @@ -156,6 +164,7 @@ fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) { /// accessible selected state. fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = tl!(label); let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click()); if on || resp.hovered() { @@ -215,7 +224,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; @@ -266,12 +275,12 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { .collect() }; if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + if ui.button(egui::RichText::new(tl!("Delete Repeated Word")).strong()).clicked() { changed = Some(splice(ls + from, "")); ui.close(); } } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + ui.label(egui::RichText::new(tl!("(no spelling suggestions)")).weak()); } for to in &w.suggestions { if ui.button(egui::RichText::new(to).strong()).clicked() { @@ -279,11 +288,11 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } } - if ui.button("Ignore All").clicked() { + if ui.button(tl!("Ignore All")).clicked() { app.squiggles.ignore(&w.word); ui.close(); } - if w.delete_from.is_none() && ui.button("Add to Dictionary").clicked() { + if w.delete_from.is_none() && ui.button(tl!("Add to Dictionary")).clicked() { if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { app.set_status(e.to_string()); } diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs index 896f9049af..fcc73efd99 100644 --- a/crates/ui-egui/src/toast.rs +++ b/crates/ui-egui/src/toast.rs @@ -66,7 +66,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); } icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false); - if resp.on_hover_text("Close").clicked() { + if resp.on_hover_text(tl!("Close")).clicked() { close = true; } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5bfbf041e9..bb74902c4b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -50,6 +50,7 @@ fn big_button_width(text_w: f32, split: bool) -> f32 { /// 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) { let t = Tokens::get(ui.ctx()); + let label = tl!(label); let font = theme::font(11.0); let lines: Vec<&str> = wrap_label(label); let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); @@ -65,7 +66,7 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let main_resp = resp.clone(); let arrow_resp = arrow_rect.map(|r| ui.interact(r, resp.id.with("arrow"), if enabled { Sense::click() } else { Sense::hover() })); if split && let Some(a) = &arrow_resp { - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{spoken} options"))); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, options_name(&spoken))); } if let Some(c) = bg(ui, &main_resp, selected, &t) { painter.rect_filled(rect, CornerRadius::same(4), c); @@ -94,6 +95,11 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: (if split { main_resp } else { resp }, arrow_resp) } +/// The accessible name of a split control's dropdown half: `Bullets options`. +fn options_name(name: &str) -> String { + crate::i18n::fmt(tl!("{name} options"), &[("name", name)]) +} + fn wrap_label(label: &str) -> Vec<&str> { if let Some((a, b)) = label.split_once('\n') { return vec![a, b]; @@ -133,7 +139,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) { - small_button_named(ui, icon, label, if label.is_empty() { "Options" } else { label }, enabled, selected, split) + small_button_named(ui, icon, label, if label.is_empty() { tl!("Options") } else { label }, enabled, selected, split) } /// A small button whose accessible name is independent of its visible label. Split arrows are @@ -149,6 +155,7 @@ pub fn small_button_named( split: bool, ) -> (Response, Option) { let t = Tokens::get(ui.ctx()); + let (label, name) = (tl!(label), tl!(name)); let font = theme::font(12.0); let tw = if label.is_empty() { 0.0 } else { ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0 }; let arrow_w = if split { 12.0 } else { 0.0 }; @@ -172,7 +179,7 @@ pub fn small_button_named( if split { main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name)); if let Some(a) = &arrow_resp { - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options"))); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, options_name(name))); } } if let Some(c) = bg(ui, &main_resp, selected, &t) { @@ -245,6 +252,7 @@ fn split_popup_id(main: &Response) -> egui::Id { /// [`small_button`] items around it. pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response { let t = Tokens::get(ui.ctx()); + let label = tl!(label); 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; let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 22.0), if enabled { Sense::click() } else { Sense::hover() }); @@ -263,6 +271,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 t = Tokens::get(ui.ctx()); + let tip = tl!(tip); 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); if let Some(c) = bg(ui, &resp, on, &t) { @@ -277,6 +286,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 t = Tokens::get(ui.ctx()); + let tip = tl!(tip); 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() }); main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); @@ -285,7 +295,7 @@ pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: resp.id.with("a"), if enabled { Sense::click() } else { Sense::hover() }, ); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{tip} options"))); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, options_name(tip))); let painter = ui.painter_at(rect); if let Some(c) = bg(ui, &main, false, &t) { painter.rect_filled(main.rect, CornerRadius::same(4), c); @@ -384,7 +394,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - let t = Tokens::get(ui.ctx()); let mut out = None; ui.set_min_width(220.0); - ui.label(egui::RichText::new("Theme Colors").font(theme::bold(12.0))); + ui.label(egui::RichText::new(tl!("Theme Colors")).font(theme::bold(12.0))); let slots = [ SchemeSlot::Lt1, SchemeSlot::Dk1, @@ -446,7 +456,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - } }); ui.add_space(4.0); - ui.label(egui::RichText::new("Standard Colors").font(theme::bold(12.0))); + ui.label(egui::RichText::new(tl!("Standard Colors")).font(theme::bold(12.0))); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; for (c, name) in deckcraft_color::STANDARD_COLORS { @@ -465,8 +475,8 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - let id = ui.scope_id().with("hex"); let mut hex: String = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); ui.horizontal(|ui| { - ui.label("More Colors… #"); - let r = ui.add(egui::TextEdit::singleline(&mut hex).desired_width(70.0).hint_text("RRGGBB")); + ui.label(tl!("More Colors… #")); + let r = ui.add(egui::TextEdit::singleline(&mut hex).desired_width(70.0).hint_text(tl!("RRGGBB"))); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && let Some(c) = Rgba::from_hex(&hex) @@ -475,7 +485,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - } }); ui.data_mut(|d| d.insert_temp(id, hex)); - if ui.button("Eyedropper").on_hover_text("Click the slide to take the colour under the pointer").clicked() { + if ui.button(tl!("Eyedropper")).on_hover_text(tl!("Click the slide to take the colour under the pointer")).clicked() { crate::eyedropper::request(ui.ctx()); ui.close(); } @@ -518,12 +528,15 @@ pub fn gallery( /// value when committed. pub fn spinner(ui: &mut Ui, id: &str, label: &str, value: f64, step: f64, suffix: &str, width: f32) -> Option { let t = Tokens::get(ui.ctx()); + let label = tl!(label); let key = ui.scope_id().with(id); let mut text: String = ui.data_mut(|d| d.get_temp::(key)).unwrap_or_else(|| fmt_num(value, suffix)); let mut out = None; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; - let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0))).accessible_name(format!("{label} value")); + let r = ui + .add(egui::TextEdit::singleline(&mut text).desired_width(width).font(theme::font(12.0))) + .accessible_name(crate::i18n::fmt(tl!("{label} value"), &[("label", label)])); if r.has_focus() { ui.data_mut(|d| d.insert_temp(key, text.clone())); } else { @@ -537,9 +550,9 @@ pub fn spinner(ui: &mut Ui, id: &str, label: &str, value: f64, step: f64, suffix } let (rect, _) = ui.allocate_exact_size(vec2(12.0, 22.0), Sense::hover()); let up = ui.interact(Rect::from_min_max(rect.min, pos2(rect.max.x, rect.center().y)), key.with("up"), Sense::click()); - up.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Increase {label}"))); + up.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::fmt(tl!("Increase {label}"), &[("label", label)]))); let down = ui.interact(Rect::from_min_max(pos2(rect.min.x, rect.center().y), rect.max), key.with("dn"), Sense::click()); - down.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("Decrease {label}"))); + down.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::i18n::fmt(tl!("Decrease {label}"), &[("label", label)]))); let p = ui.painter(); let c = rect.center(); p.line_segment([pos2(c.x - 3.0, c.y - 3.0), pos2(c.x, c.y - 6.0)], Stroke::new(1.0, t.text_dim)); @@ -583,16 +596,17 @@ pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { p.line_segment([pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y)], Stroke::new(1.2, t.text)); p.line_segment([pos2(c.x + 2.0, c.y), pos2(c.x - 2.0, c.y + 4.0)], Stroke::new(1.2, t.text)); } - p.text(pos2(rect.min.x + 18.0, rect.center().y), Align2::LEFT_CENTER, title, theme::bold(13.0), t.text); + p.text(pos2(rect.min.x + 18.0, rect.center().y), Align2::LEFT_CENTER, tl!(title), theme::bold(13.0), t.text); if resp.clicked() { !open } else { open } } /// A button with an optional label and a painted dropdown chevron (the UI font has no ▾). pub fn drop_button(ui: &mut Ui, label: &str, size: egui::Vec2, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = tl!(label); let r = ui .add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size)) - .on_hover_text(if label.is_empty() { "Options" } else { label }); + .on_hover_text(if label.is_empty() { tl!("Options") } else { label }); let col = if enabled { t.text_dim } else { t.text_faint }; chevron(ui.painter(), egui::pos2(r.rect.max.x - 8.0, r.rect.center().y), 3.0, col); r diff --git a/docs/gaps.md b/docs/gaps.md index c4a875f1cf..305d48d321 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** minor (English and German interface, #15; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **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: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with @@ -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 | Localization: English and German only | Localization | B (infra + 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,11 @@ 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). +- **English and German only** (#15, #30; Ukrainian PR #34 open). The interface reads the shared + craft-i18n catalogs and follows the system language; German covers the ribbon, menus, palette, + status bar, File tab, dialogs and panes. PowerPoint for Mac ships 26 languages: 3–16 h per + language plus native review. Beta needs at least Simplified Chinese, Spanish, French and Japanese + as well (~25–40 h). - **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. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 231177ad11..abe04742f5 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,11 +1,19 @@ # 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-28 · **Change:** major (interface catalogs from craft-i18n, language setting, German; previous: 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-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 +DeckCraft's interface is shown in English or German. Strings stay English literals in the code and +are looked up in the shared [craft-i18n](https://github.com/storytold/craft-i18n) catalogs when the +UI is drawn (`tl!`, `crates/ui-egui/src/i18n.rs`); `build.rs` embeds them from `CRAFT_I18N_DIR` at +the commit in `craft-i18n.rev`, and a build without it is English only. The interface follows the +system language (#15), or Preferences › Interface language and `app.language` (`auto`, `en`, +`de`). Command ids, engine labels returned to scripts, the control channel, the CLI and MCP stay +English. Still English: slide placeholder prompts (drawn by the renderer), status messages from the +engine, the native macOS menu bar and some dialog choice lists. Other requests: #30 and PR #34 +(Ukrainian) and the language PRs listed in storytold/craft-i18n#48, which can now land as +catalog-only PRs there. Dimension score: **~15% ready** (estimated: English complete, German +mostly, slide-text script support partly there), **55–90 h** to parity plus native-speaker review. ## How the numbers are measured @@ -34,18 +42,19 @@ support partly there), **55–90 h** to parity plus native-speaker review. | 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 | -| German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 | +| German | de | ~840 rows (654 DeckCraft, 185 shared): ribbon, menus, palette, status bar, File tab, dialogs, panes | partly / yes / no help | full (Latin) | no | **partial** | yes | 1–3 | | 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: **German** (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; +Per-language hours assume the infrastructure exists, which it now does: the craft-i18n runtime +(lookup, fallback, plurals, locale matching), a language setting that follows the system (#15) and a +test that the embedded catalogs parse cleanly. Adding a language is a `locales//deckcraft.tsv` +in craft-i18n plus its code in `LANGUAGES`. Still to do once: an app-side report of source texts +without a translation (craft-i18n `docs/integration.md`). 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. @@ -61,6 +70,7 @@ of every text in the file and the default for new text (`format.languageAll`). | Date | Change | Summary | |---|---|---| +| 2026-10-28 | major | Interface catalogs from craft-i18n, system language and Preferences › Interface language (#15); German partial (~840 rows) | | 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 | 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 | From 0497b826f0a69d9225dc43c57e9b36a4f3bd1155 Mon Sep 17 00:00:00 2001 From: filforce95 <49290121+filforce95@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:29:42 +0100 Subject: [PATCH 02/11] i18n: address CodeRabbit review on #1488 Translate the Photo Album accessible names and Quick Access Toolbar labels before a shortcut is appended; align the localization estimate and the beta scope in the docs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/ribbon/insert.rs | 4 ++-- crates/ui-egui/src/ribbon/qat.rs | 6 +++--- docs/gaps.md | 2 +- docs/localization-parity.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs index 32689f9f17..700242f964 100644 --- a/crates/ui-egui/src/ribbon/insert.rs +++ b/crates/ui-egui/src/ribbon/insert.rs @@ -11,10 +11,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(tl!("Photo Album options")); - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "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!({})); diff --git a/crates/ui-egui/src/ribbon/qat.rs b/crates/ui-egui/src/ribbon/qat.rs index e0fbc3f36c..acfc916a7f 100644 --- a/crates/ui-egui/src/ribbon/qat.rs +++ b/crates/ui-egui/src/ribbon/qat.rs @@ -105,7 +105,7 @@ fn tip(q: &QatItem) -> String { 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)), + Some(s) => format!("{} ({})", tl!(&q.label), crate::ribbon::pretty_shortcut(s)), None => q.label.clone(), } } @@ -136,7 +136,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { 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, tl!(&c.label)).changed() { let key = if on { "add" } else { "remove" }; let _ = command(app, &json!({key: c.id})); } @@ -146,7 +146,7 @@ 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, tl!(&q.label)).changed() { let _ = command(app, &json!({"remove": q.id, "params": q.params})); } } diff --git a/docs/gaps.md b/docs/gaps.md index 305d48d321..f894d7829c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | Localization: English and German only | Localization | B (infra + 5 languages) | 45–80 | [localization-parity.md](localization-parity.md) | +| 5 | Localization: English and German only | Localization | B (4 more 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) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index abe04742f5..f119d5af65 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -14,7 +14,7 @@ engine, the native macOS menu bar and some dialog choice lists. Other requests: (Ukrainian) and the language PRs listed in storytold/craft-i18n#48, which can now land as catalog-only PRs there. Dimension score: **~15% ready** (estimated: English complete, German mostly, slide-text script -support partly there), **55–90 h** to parity plus native-speaker review. +support partly there), **45–80 h** to parity plus native-speaker review. ## How the numbers are measured From 5b50cc8c31e1d1fcc79d068bf465d549156a8848 Mon Sep 17 00:00:00 2001 From: filforce95 <49290121+filforce95@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:51:25 +0100 Subject: [PATCH 03/11] Format sorter.rs after the merge Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/sorter.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 1facf2aa59..f84801abf0 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -495,7 +495,11 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck let cur_title = s.title(); let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_title.clone())); let resp = ui.add( - egui::TextEdit::singleline(&mut v).hint_text(tl!("Slide title")).font(theme::bold(14.0)).frame(egui::Frame::NONE).desired_width(f32::INFINITY), + egui::TextEdit::singleline(&mut v) + .hint_text(tl!("Slide title")) + .font(theme::bold(14.0)) + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY), ); if resp.gained_focus() { let _ = app.run("slide.go", json!({"index": i})); @@ -522,8 +526,9 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck let mut v: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| cur_text.clone())); ui.horizontal(|ui| { ui.add_space(36.0); - let resp = ui - .add(egui::TextEdit::multiline(&mut v).hint_text(tl!("Slide text")).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1)); + let resp = ui.add( + egui::TextEdit::multiline(&mut v).hint_text(tl!("Slide text")).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(1), + ); if resp.gained_focus() { let _ = app.run("slide.go", json!({"index": i})); } From 87e51745ae1a9549f4cce071e58292ca9788074c Mon Sep 17 00:00:00 2001 From: filforce95 <49290121+filforce95@users.noreply.github.com> Date: Sun, 1 Nov 2026 08:28:19 +0100 Subject: [PATCH 04/11] i18n: address review on #1488 Toolbar labels saved from a two-line ribbon caption (New Slide) find their catalog row, the toolbar's add/remove menu item is translated, and a saved language code this build doesn't offer is kept instead of being reset to auto, so a local build without catalogs doesn't lose a release's choice. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/preferences.rs | 3 ++- crates/ui-egui/src/i18n.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/i18n/tests.rs | 16 ++++++++++++++-- crates/ui-egui/src/ribbon/qat.rs | 14 +++++++------- crates/ui-egui/src/state.rs | 9 +++++++-- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index a756e493db..4eb5b8d4bb 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -33,7 +33,8 @@ pub(super) fn restore_preferences(app: &mut SlideApp, d: &Dialog) { /// catalogs for, each named in itself. It applies at once, like the other controls here. pub(super) fn language_row(app: &mut SlideApp, ui: &mut egui::Ui) { let auto = crate::i18n::AUTO; - let name = |code: &str| if code == auto { tl!("System language").to_string() } else { crate::i18n::Lang::from_pref(code).name().to_string() }; + // A saved code this build doesn't offer follows the system, so it shows as System language. + let name = |code: &str| crate::i18n::Lang::from_code(code).map_or_else(|| tl!("System language").to_string(), |l| l.name().to_string()); let mut chosen = app.ui.language.clone(); ui.horizontal(|ui| { ui.label(tl!("Interface language:")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7ac3b65f74..da55e0eb00 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -206,6 +206,29 @@ pub fn tn(n: u64, one: &str, other: &str) -> String { lang.i18n().trn(lang.lang, n, one, other) } +/// A ribbon caption whose line break was saved as a space (a Quick Access Toolbar label such as +/// `New Slide`, from `New\nSlide`) in the current language, on one line. +pub fn t_flat(s: &str) -> String { + let lang = current(); + if has(lang, s) { + return t(s).replace('\n', " "); + } + // Try the break at each space: captions have one at most. + let found = s.match_indices(' ').map(|(i, _)| format!("{}\n{}", &s[..i], &s[i + 1..])).find(|k| has(lang, k)); + found.map_or_else(|| s.to_string(), |k| t(&k).replace('\n', " ")) +} + +/// Whether `code` is shaped like a language code (`fr`, `pt-br`, `zh_Hans`), offered or not. +/// A saved setting of that shape is kept when this build doesn't offer it, so switching between +/// a release and a build without catalogs doesn't lose the choice. +pub fn well_formed_code(code: &str) -> bool { + let mut parts = code.split(['-', '_']); + let primary = parts.next().unwrap_or(""); + (2..=3).contains(&primary.len()) + && primary.bytes().all(|b| b.is_ascii_alphabetic()) + && parts.all(|p| (1..=8).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_alphanumeric())) +} + /// A ribbon location (`Home › Font`) in the current language, segment by segment. pub fn location(loc: &str) -> String { loc.split(" › ").map(t).collect::>().join(" › ") diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 0e8630da7d..eed221a988 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -43,6 +43,10 @@ fn a_string_without_a_translation_stays_english() { set_current(de); assert_eq!(fmt(t("Slide {n} of {total}"), &[("n", "2"), ("total", "9")]), "Folie 2 von 9"); assert_eq!(location("Home › Insert"), "Start › Einfügen"); + // A Quick Access Toolbar label is a ribbon caption with its line break saved as a space. + assert_eq!(t_flat("New Slide"), "Neue Folie"); + assert_eq!(t_flat("Insert"), "Einfügen"); + assert_eq!(t_flat("Not in the catalog"), "Not in the catalog"); set_current(Lang::EN); } @@ -59,9 +63,17 @@ fn the_language_setting_is_auto_or_an_offered_code() { assert_eq!(Lang::from_pref("a code from a newer version"), Lang::EN); assert_eq!(Lang::all().next(), Some(Lang::EN), "English is first and always offered"); // A damaged or hand-edited `ui.json` follows the system. - let ui = UiState { language: "klingon".into(), ..Default::default() }.sanitized(); - assert_eq!(ui.language, AUTO); + for bad in ["klingon", "", "d", "de-", "