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 8382a760b5..9ba27d3d68 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 b4059e6893..c8d9d2ba09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1296,6 +1296,7 @@ dependencies = [ name = "deckcraft-ui-egui" version = "0.15.0" dependencies = [ + "craft-i18n", "craft-ui", "craft-update", "deckcraft-anim", @@ -1314,6 +1315,7 @@ dependencies = [ "serde", "serde_json", "skrifa 0.47.0", + "sys-locale", "toml", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index ab2b742915..e9b15403fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,12 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f # craft-update: app-specific pretend versions apply to startup and manual checks. craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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 edd316276f..008f43131d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away +> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-08 · **Change:** minor (Russian interface, partial; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -9,6 +10,8 @@ > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **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-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: 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) @@ -72,7 +75,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) | @@ -107,7 +110,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, German and Russian. 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 | @@ -121,12 +124,12 @@ 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 -ships 26. +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`). Other languages shipped: Russian (`ru`, +partial). PowerPoint for Mac ships 26. ## Upcoming @@ -154,6 +157,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-11-08 | Russian interface (`ru`): the craft-i18n catalog covers the ribbon, menus, command palette, status bar, File tab, dialogs and panes; spinners read numbers as the interface language writes them | | 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered | | 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop | | 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) | @@ -170,6 +174,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text | | 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | +| 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-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | | 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | @@ -340,6 +345,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-08 | minor | Russian interface (`ru`), partial. Scores unchanged | | 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged | | 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged | | 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | @@ -348,6 +354,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | +| 2026-10-28 | minor | Interface languages from craft-i18n with German (#15): Localization ~8% → ~15%, German row partial | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged | diff --git a/craft-i18n.rev b/craft-i18n.rev new file mode 100644 index 0000000000..2759ad3f2b --- /dev/null +++ b/craft-i18n.rev @@ -0,0 +1 @@ +bfed1eb8df6f6fa9afe6e1c6b4683e110fd9560d diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 166eba2208..58c868b6c1 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,6 +21,8 @@ deckcraft-media = { workspace = true } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_extras = { workspace = true } 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 8b262f12ca..f08ff3ccf9 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,4 +1,5 @@ -//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time. +//! Bake the contributor credits into the binary as static Rust tables, and embed the craft-i18n +//! interface catalogs. Nothing is read at run time. //! //! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose //! people.toml entry is verified. @@ -6,6 +7,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 _; @@ -23,6 +30,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 178ab24bad..f0f7b19fd3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -290,6 +290,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 718b95acea..3229369924 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()) @@ -43,6 +44,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) } @@ -234,6 +242,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 8146697286..59b6917b1c 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -333,7 +333,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); @@ -345,7 +345,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 { @@ -398,7 +398,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { // page past the right margin and clip the tiles (#1421). ui.set_max_width(ui.available_width().min(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 @@ -439,7 +439,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)); @@ -467,31 +467,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 { @@ -506,7 +507,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..6259c711e1 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() { @@ -154,6 +154,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { /// A large square command button (Print, Create PDF, Check for Issues…). pub(super) fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let label = tl!(label); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); @@ -210,11 +211,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 +226,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 +237,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 +253,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 +290,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 3ddbda1a33..04842bea86 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('/'))); } @@ -214,7 +214,7 @@ pub(super) fn about(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."); @@ -222,7 +222,7 @@ pub(super) fn about(app: &mut SlideApp, ui: &mut Ui) { if task(ui, Icon::Help, "Credits", "Credits", "The people and models who built DeckCraft.", true) { 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); }); ui.add_space(40.0); diff --git a/crates/ui-egui/src/backstage/print.rs b/crates/ui-egui/src/backstage/print.rs index db5d6b9724..7db379f5e1 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"})); @@ -307,19 +307,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) @@ -338,7 +338,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; @@ -359,8 +359,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..9f64230347 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 } @@ -194,7 +194,7 @@ fn exact_of(d: &Dialog, key: &str) -> Option { fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { let mut v = field_of(d, key); ui.horizontal(|ui| { - let l = ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label)); + let l = ui.add_sized(vec2(148.0, 20.0), egui::Label::new(tl!(label))); ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0)).labelled_by(l.id); }); d.fields.insert(key.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 059543b615..248be0a59c 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)); }); } @@ -109,7 +109,7 @@ mod about_credits_tests { let mut block = egui::Rect::NOTHING; let mut whole = egui::Rect::NOTHING; let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - tabs = ui.label("About").rect; + tabs = ui.label(tl!("About")).rect; block = credits_block(ui, models); whole = ui.min_rect(); }); diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs index 932cd469ae..5069543db1 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 { @@ -113,13 +113,13 @@ fn dlg_slide_size(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = combo.response.labelled_by(label.id); let abbr = unit_abbr(len); let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); - field(ui, d, &format!("Width ({abbr}):"), "w", &w_seed); - field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); + field(ui, d, &crate::i18n::fmt(tl!("Width ({abbr}):"), &[("abbr", abbr)]), "w", &w_seed); + field(ui, d, &crate::i18n::fmt(tl!("Height ({abbr}):"), &[("abbr", 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:"); - let portrait = ui.radio(h > w, "Portrait").clicked(); - let landscape = ui.radio(w >= h, "Landscape").clicked(); + ui.label(tl!("Orientation:")); + let portrait = ui.radio(h > w, tl!("Portrait")).clicked(); + let landscape = ui.radio(w >= h, tl!("Landscape")).clicked(); // Picking the other orientation swaps the width and height fields. if portrait && w > h || landscape && h > w { put_slide_dim(d, "w", h, ppu); @@ -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"), @@ -262,10 +262,10 @@ fn dlg_setup_show(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let kc = check(ui, d, "Keep mouse cursor visible", "kc", app.ui.show_keep_cursor); 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 label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + let field = ui.label(tl!("Slides")); + let label = if chosen.is_empty() { tl!("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() { + if ui.selectable_label(chosen.is_empty(), tl!("All slides")).clicked() { d.fields.insert("custom".into(), String::new()); } for n in &names { @@ -292,21 +292,25 @@ fn dlg_quit(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); return true; } - 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."); + let what = if names.len() == 1 { + crate::i18n::fmt(tl!("“{name}”"), &[("name", names[0].as_str())]) + } else { + crate::i18n::fmt(&crate::i18n::tn(names.len() as u64, "{n} presentation", "{n} presentations"), &[("n", names.len().to_string().as_str())]) + }; + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Do you want to save the changes you made to {what}?"), &[("what", what.as_str())])).strong()); + 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; @@ -314,7 +318,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; } }); @@ -325,18 +329,20 @@ fn dlg_quit(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool { 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( + egui::RichText::new(crate::i18n::fmt(tl!("Do you want to save the changes you made to “{name}”?"), &[("name", name.as_str())])).strong(), + ); + 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". A page @@ -347,7 +353,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; } }); @@ -357,7 +363,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() { @@ -427,7 +433,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| { @@ -440,7 +446,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()); } }); @@ -560,7 +566,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 { @@ -605,9 +611,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"); @@ -618,9 +624,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 { @@ -648,12 +654,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()); } @@ -673,7 +679,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)); @@ -708,7 +714,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"), @@ -728,7 +734,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", @@ -759,7 +765,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()); } } @@ -768,8 +774,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()); @@ -813,7 +819,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); @@ -821,9 +827,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) { @@ -853,7 +859,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", "$€£¥¢₹₩₽₺₿"), @@ -880,9 +886,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() { @@ -906,7 +912,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)); @@ -947,7 +953,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| { @@ -957,14 +963,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()); @@ -978,8 +984,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; @@ -1007,9 +1013,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) { @@ -1066,7 +1072,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 { @@ -1077,7 +1083,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]})); @@ -1099,7 +1105,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; @@ -1126,8 +1132,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"); @@ -1144,11 +1150,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})); } }); @@ -1174,11 +1180,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() { @@ -1193,17 +1199,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()); } @@ -1243,7 +1249,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 [ @@ -1260,7 +1266,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 { @@ -1292,23 +1298,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(); @@ -1324,7 +1330,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(); @@ -1341,9 +1347,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 @@ -1354,7 +1360,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()); @@ -1365,20 +1371,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"); } }); @@ -1386,7 +1393,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 b9b32e56e0..f727967564 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,14 +232,14 @@ 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(); - if ui.selectable_label(!v.table, "Grab bag").clicked() { + if ui.selectable_label(!v.table, tl!("Grab bag")).clicked() { v.table = false; } - if ui.selectable_label(v.table, "Table").clicked() { + if ui.selectable_label(v.table, tl!("Table")).clicked() { v.table = true; } }); @@ -247,7 +247,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); ui.separator(); 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 { // The table scrolls vertically itself, under a fixed header; it only scrolls sideways // once its columns are dragged wider than the dialog. @@ -257,7 +257,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { 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()); } @@ -414,7 +414,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..d2baca389b 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,34 @@ 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; } + // Put the saved value back as it was, even a code this build doesn't offer (a newer build's + // language): `app.language` would refuse it and leave the language picked in the dialog. + if let Some(language) = before.get("language").and_then(Value::as_str) { + app.ui.language = language.to_string(); + } 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; + // 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:")); + 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..ed2af86001 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,265 @@ +//! 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; 3] = [ + "en", // German, neutral across Germany, Austria and Switzerland; `de-DE`, `de-AT`, `de_CH.UTF-8` … + // resolve here. + "de", // Russian; `ru-RU`, `ru-SK`, `ru-UA` and `ru_RU.UTF-8` resolve here. + "ru", +]; + +/// 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 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(" › ") +} + +/// 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..ef3962aba1 --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,197 @@ +#![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"); + // 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); +} + +#[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. + for bad in ["klingon", "", "d", "de-", "