From 033248d5d8f2d46ebdeea4fd6e1c27d2585564a3 Mon Sep 17 00:00:00 2001 From: Dmytro Matviichuk Date: Fri, 9 Oct 2026 19:59:09 +0200 Subject: [PATCH] i18n #30: add Ukrainian interface and language preference Co-authored-by: Codex --- ATTRIBUTION.md | 1 + Cargo.lock | 10 + ROADMAP.md | 15 +- apps/deckcraft-web/Cargo.toml | 2 +- apps/deckcraft-web/src/web.rs | 9 +- apps/deckcraft/Cargo.toml | 1 + apps/deckcraft/src/main.rs | 14 +- crates/render/src/lib.rs | 9 +- crates/render/src/tests.rs | 22 + crates/ui-egui/src/canvas.rs | 30 +- crates/ui-egui/src/credits.rs | 58 +- crates/ui-egui/src/dialogs.rs | 214 +++--- crates/ui-egui/src/fillui.rs | 22 +- crates/ui-egui/src/i18n.rs | 358 +++++++++ crates/ui-egui/src/i18n/uk.tsv | 1234 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 29 +- crates/ui-egui/src/media.rs | 4 +- crates/ui-egui/src/menus.rs | 13 +- crates/ui-egui/src/panes.rs | 136 ++-- crates/ui-egui/src/ribbon.rs | 310 ++++---- crates/ui-egui/src/show.rs | 40 +- crates/ui-egui/src/sorter.rs | 6 +- crates/ui-egui/src/status.rs | 21 +- crates/ui-egui/src/textures.rs | 58 +- crates/ui-egui/src/thumbs.rs | 32 +- crates/ui-egui/src/widgets.rs | 29 +- docs/gaps.md | 12 +- docs/localization-parity.md | 28 +- docs/roadmap.md | 7 +- docs/target-app-parity.md | 5 +- 30 files changed, 2288 insertions(+), 441 deletions(-) create mode 100644 crates/ui-egui/src/i18n.rs create mode 100644 crates/ui-egui/src/i18n/uk.tsv diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c8137dfbf..7af26b760 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,7 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | `crates/media/tests/fixtures/tone*` (`tone.mp3`, `.m4a`, `.aac`, `.ogg`, `.opus`, `.flac`, `.wma`, `tone-alac.m4a`) | DeckCraft contributors | synthesised 0.5 s 440 Hz sine, encoded by `crates/media/tests/fixtures/make.sh` (ffmpeg as the encoder only) | CC0-1.0 | Audio decoding tests | | `crates/media/tests/fixtures/clip-*` (`clip-h264.mp4`, `clip-h264.mov`, `clip-hevc.mp4`, `clip-vp9.webm`, `clip-av1.mkv`) | DeckCraft contributors | 6 frames of a colour gradient generated by `make.sh` (python3), encoded by ffmpeg (x264 / x265 / libvpx / SVT-AV1 as encoders only) | CC0-1.0 | Video decoding tests | | `crates/media/tests/fixtures/make.sh` | DeckCraft contributors | this repository | MIT OR Apache-2.0 | Regenerates the media fixtures | +| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk, assisted by Codex | Contributor-original Ukrainian translation in this repository; terminology checked against VESUM 6.8.6 and ULIF (validation tools/data are not bundled) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Ukrainian interface text | ## Bundled through dependencies diff --git a/Cargo.lock b/Cargo.lock index e50d814e4..a397e870b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,6 +890,7 @@ dependencies = [ "log", "rfd", "serde_json", + "sys-locale", "winit", "winresource", ] @@ -4278,6 +4279,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/ROADMAP.md b/ROADMAP.md index 801ddd843..d49ff8386 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Ukrainian UI catalog and language selection) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere @@ -84,7 +84,10 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b ## Languages -English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail: +English and Ukrainian: Preferences offers System language, English and Українська. Ukrainian +OS/browser locales select Ukrainian automatically; explicit desktop choices persist in `ui.json`. +Menus, ribbon, command palette, panels, dialogs and presenter controls use the interface catalog. +Presentation content and command IDs retain their original values. Detail: [docs/localization-parity.md](docs/localization-parity.md). | Language | Code | UI strings | Status | Hours to full | @@ -102,8 +105,8 @@ English only today; no string catalog yet (Ukrainian is in review as PR #34). De | 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. +Other languages shipped: **Ukrainian (uk)**, with English fallback and bundled Cyrillic glyph +coverage. PowerPoint for Mac ships 26 languages; additional catalogs and native review remain. ## Upcoming @@ -113,7 +116,7 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md). image paste (#53), editor soak tests. 10–20 h. 2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG. 36–64 h. -3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h. +3. **Localization:** additional catalogs for zh-Hans, es, fr, de, ja, native review and IME. 27–46 h. 4. **Output:** vector PDF, printing. 10–16 h. 5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline, presenter view on the right display, native macOS menu bar, Format Shape pane. 33–56 h. @@ -128,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-10 | Ukrainian UI catalog, system-language detection and persisted language choice (#15, #30); translated placeholder prompts preserve presentation content | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | | 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG | @@ -139,6 +143,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Added Ukrainian UI and language selection to the reorganized language summary | | 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials | | 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% | | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 7014421d8..fa1171ed3 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -27,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Navigator", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f0199a558..014af2412 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -38,6 +38,7 @@ pub fn start() { } let inbox: Inbox = Inbox::default(); let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); + app.services.system_locale = web_sys::window().and_then(|w| w.navigator().language()); let q = query(); if !q.contains("blank") { if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) { @@ -111,10 +112,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services { let inbox = open_inbox.clone(); let ctx = ctx.clone(); let dialog = match purpose { - "picture" => rfd::AsyncFileDialog::new().add_filter("Pictures", PICTURE_EXTS), - "audio" => rfd::AsyncFileDialog::new().add_filter("Audio", AUDIO_EXTS), - "video" => rfd::AsyncFileDialog::new().add_filter("Video", VIDEO_EXTS), - _ => rfd::AsyncFileDialog::new().add_filter("Presentations", PRESENTATION_EXTS), + "picture" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Pictures"), PICTURE_EXTS), + "audio" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Audio"), AUDIO_EXTS), + "video" => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Video"), VIDEO_EXTS), + _ => rfd::AsyncFileDialog::new().add_filter(deckcraft_ui_egui::i18n::tr("Presentations"), PRESENTATION_EXTS), }; wasm_bindgen_futures::spawn_local(async move { let Some(file) = dialog.pick_file().await else { diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml index 26ea30812..bbfaf9db0 100644 --- a/apps/deckcraft/Cargo.toml +++ b/apps/deckcraft/Cargo.toml @@ -11,6 +11,7 @@ default-run = "deckcraft" workspace = true [dependencies] +sys-locale = "0.3.2" deckcraft-engine = { workspace = true } deckcraft-ui-egui = { workspace = true } deckcraft-media = { workspace = true } diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs index 7b5e48bbe..7bb70469b 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -91,20 +91,21 @@ fn save_prefs(app: &SlideApp) { fn services() -> Services { Services { + system_locale: sys_locale::get_locale(), pick_open: Some(Box::new(|purpose: &str| { let d = rfd::FileDialog::new(); let d = match purpose { - "picture" => d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]), + "picture" => d.add_filter(deckcraft_ui_egui::i18n::tr("Pictures"), &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff"]), "audio" => d.add_filter( - "Audio", + deckcraft_ui_egui::i18n::tr("Audio"), &["wav", "mp3", "m4a", "m4b", "aac", "flac", "ogg", "oga", "opus", "aif", "aiff", "aifc", "caf", "wma", "weba", "mka"], ), - "video" => d.add_filter("Video", &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]), + "video" => d.add_filter(deckcraft_ui_egui::i18n::tr("Video"), &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]), _ => d - .add_filter("Presentations", &["deckcraft", "pptx", "potx", "ppsx"]) + .add_filter(deckcraft_ui_egui::i18n::tr("Presentations"), &["deckcraft", "pptx", "potx", "ppsx"]) .add_filter("DeckCraft", &["deckcraft"]) .add_filter("PowerPoint", &["pptx", "potx", "ppsx"]) - .add_filter("Outline", &["txt", "md"]), + .add_filter(deckcraft_ui_egui::i18n::tr("Outline"), &["txt", "md"]), }; d.pick_file().map(|p| p.to_string_lossy().to_string()) })), @@ -205,7 +206,8 @@ fn main() -> eframe::Result { if let Ok(v) = app.session.execute("file.recovery.open", &serde_json::json!({})) && let Some(n) = v.get("opened").and_then(|o| o.as_array()).map(Vec::len).filter(|n| *n > 0) { - app.set_status(format!("Recovered {n} unsaved presentation{} from the last session", if n == 1 { "" } else { "s" })); + deckcraft_ui_egui::i18n::set_current(app.ui.interface_language.resolve(app.services.system_locale.as_deref())); + app.set_status(deckcraft_ui_egui::i18n::recovered(n)); } } app.integrated_titlebar = cfg!(target_os = "macos"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6dde577a0..3e80d735c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -138,6 +138,8 @@ pub struct RenderOpts<'a> { pub scale: f64, /// Editor view: placeholder prompts and dashed placeholder outlines. pub edit: bool, + /// Optional interface prompt provider; custom layout prompts and document text stay intact. + pub placeholder_prompt: Option &'static str>, /// Draw shapes marked hidden (Selection pane) — the editor hides them like the show does. pub show_hidden: bool, /// Per-shape animation state. @@ -166,6 +168,7 @@ impl Default for RenderOpts<'_> { RenderOpts { scale: 1.0, edit: false, + placeholder_prompt: None, show_hidden: false, state: None, skip: &[], @@ -773,7 +776,7 @@ impl Renderer { draw_layout_with(ctx, &l, tr, m, &st.paras); } else if f.opts.edit && s.ph.is_some() { let kind = s.ph_type().unwrap_or(PhType::Body); - let prompt_body = prompt_body(rctx, s, kind); + let prompt_body = prompt_body(rctx, s, kind, f.opts.placeholder_prompt.map(|prompt| prompt(kind))); if !prompt_body.is_empty() { let grey = Rgba::rgb(0x59, 0x59, 0x59); draw_text(ctx, rctx, s, &prompt_body, tr, m, &f.fields, Some(grey)); @@ -1009,7 +1012,7 @@ fn arrowheads(ctx: &mut RenderContext, rctx: &Ctx, line: &Line, ph: Option } /// The prompt shown in an empty placeholder, formatted like its content would be. -fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { +fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType, interface_prompt: Option<&str>) -> TextBody { // Layout/master prompt text when the layout supplies a custom one. let (lp, _) = resolve::parents(rctx, s); let custom = lp.and_then(|l| l.ph.as_ref().filter(|p| p.has_custom_prompt).and(l.text.as_ref())).filter(|t| !t.is_empty()); @@ -1019,7 +1022,7 @@ fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody { if matches!(rctx.owner, resolve::Owner::Layout | resolve::Owner::Master) { return TextBody::default(); } - let txt = kind.prompt(); + let txt = interface_prompt.unwrap_or_else(|| kind.prompt()); if txt.is_empty() || kind.is_footer_kind() { return TextBody::default(); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index ccb4472fa..17bb7d863 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -523,3 +523,25 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { let own = px(&run_deck(RunProps { link, fill: red.clone(), ..Default::default() })); assert_eq!(diff(&own, &px(&run_deck(RunProps { fill: red, underline: Some("sng".into()), ..Default::default() }))), 0); } + +#[test] +fn interface_placeholder_prompts_preserve_custom_prompts_and_document_data() { + let mut p = Presentation::default(); + let slide = p.slides.first().unwrap(); + let shape = slide.shapes.iter().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap(); + let c = Ctx::for_slide(&p, slide).unwrap(); + let before = p.clone(); + assert_eq!(prompt_body(&c, shape, PhType::Title, Some("Натисніть, щоб додати заголовок")).text(), "Натисніть, щоб додати заголовок"); + assert_eq!(p, before); + + let layout_id = slide.layout; + let master = Arc::make_mut(p.masters.first_mut().unwrap()); + let layout = master.layouts.iter_mut().find(|layout| layout.id == layout_id).unwrap(); + let shape = layout.shapes.iter_mut().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap(); + shape.ph.as_mut().unwrap().has_custom_prompt = true; + shape.text = Some(TextBody::from_text("Click to add title")); + let slide = p.slides.first().unwrap(); + let shape = slide.shapes.iter().find(|shape| shape.ph_type() == Some(PhType::Title) || shape.ph_type() == Some(PhType::CtrTitle)).unwrap(); + let c = Ctx::for_slide(&p, slide).unwrap(); + assert_eq!(prompt_body(&c, shape, PhType::Title, Some("Натисніть, щоб додати заголовок")).text(), "Click to add title"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d267f76cb..69a261efb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -56,7 +56,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; if st.doc.slides.is_empty() && st.selection.target == Target::Slides { let resp = ui.allocate_rect(avail, Sense::click()); - ui.painter().text(avail.center(), Align2::CENTER_CENTER, "Click to add first slide", theme::font(22.0), t.text_dim); + ui.painter().text(avail.center(), Align2::CENTER_CENTER, crate::i18n::tr("Click to add first slide"), theme::font(22.0), t.text_dim); if resp.clicked() { let _ = app.run("slide.new", json!({"layout": "title"})); } @@ -271,7 +271,7 @@ fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) { }; p.line_segment([pos2(x, top.max.y - h), pos2(x, top.max.y)], Stroke::new(1.0, t.text_faint)); if k % 4 == 0 { - p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, format!("{}", k / 4), theme::font(8.0), t.text_dim); + p.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, (k / 4).to_string(), theme::font(8.0), t.text_dim); } k += 1; } @@ -540,47 +540,47 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { resp.context_menu(|ui| { let has_sel = app.session.active().is_some_and(|d| !d.selection.shapes.is_empty()); for (l, id) in [("Cut", "edit.cut"), ("Copy", "edit.copy"), ("Paste", "edit.paste")] { - if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { + if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(crate::i18n::tr(l))).clicked() { let _ = app.run(id, json!({})); ui.close(); } } ui.separator(); if has_sel { - if ui.button("Edit Text").clicked() { + if ui.button(crate::i18n::tr("Edit Text")).clicked() { let _ = app.run("text.edit", json!({})); ui.close(); } - ui.menu_button("Bring to Front", |ui| { + ui.menu_button(crate::i18n::tr("Bring to Front"), |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({})); }); - ui.menu_button("Send to Back", |ui| { + ui.menu_button(crate::i18n::tr("Send to Back"), |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendToBack, "Send to Back", "arrange.sendToBack", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::SendBackward, "Send Backward", "arrange.sendBackward", json!({})); }); - ui.menu_button("Group", |ui| { + ui.menu_button(crate::i18n::tr("Group"), |ui| { crate::ribbon::menu_item(app, ui, crate::icons::Icon::Group, "Group", "arrange.group", json!({})); crate::ribbon::menu_item(app, ui, crate::icons::Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); }); - if ui.button("Link…").clicked() { + if ui.button(crate::i18n::tr("Link…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); } - if ui.button("Edit Alt Text…").clicked() { + if ui.button(crate::i18n::tr("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(crate::i18n::tr("Set as Default Shape")).clicked() { let _ = app.run("shape.setDefault", json!({})); ui.close(); } - if ui.button("Format Shape…").clicked() { + if ui.button(crate::i18n::tr("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format"})); ui.close(); } } else { - ui.menu_button("Layout", |ui| { + ui.menu_button(crate::i18n::tr("Layout"), |ui| { let layouts: Vec<(String, u32)> = app .session .active() @@ -593,15 +593,15 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(crate::i18n::tr("Reset Slide")).clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); } - if ui.button("Format Background…").clicked() { + if ui.button(crate::i18n::tr("Format Background…")).clicked() { let _ = app.run("view.pane", json!({"pane": "background"})); ui.close(); } - if ui.button("New Comment").clicked() { + if ui.button(crate::i18n::tr("New Comment")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("comment")); ui.close(); } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 765dec19d..b7f01233b 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -123,18 +123,20 @@ impl Contributor { /// One line with everything we know, for tooltips. pub fn summary(&self) -> String { - format!( + crate::i18n::format( "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}", - self.login, - self.prs, - self.commits, - group(self.lines_added), - group(self.lines_deleted), - signed(self.lines_delta()), - self.binary_added, - self.binary_deleted, - day(self.first_commit), - day(self.last_commit), + &[ + (self.login).to_string(), + (self.prs).to_string(), + (self.commits).to_string(), + (group(self.lines_added)).to_string(), + (group(self.lines_deleted)).to_string(), + (signed(self.lines_delta())).to_string(), + (self.binary_added).to_string(), + (self.binary_deleted).to_string(), + (day(self.first_commit)).to_string(), + (day(self.last_commit)).to_string(), + ], ) } } @@ -215,39 +217,43 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let id = egui::Id::new("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(crate::i18n::tr("Show")); for m in NameMode::ALL { - if ui.selectable_label(v.names == m, m.label()).clicked() { + if ui.selectable_label(v.names == m, crate::i18n::tr(m.label())).clicked() { v.names = m; } } ui.separator(); - ui.label("Sort"); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + ui.label(crate::i18n::tr("Sort")); + egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { - if ui.selectable_label(v.key == k, k.label().0).clicked() { + if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() { v.key = k; v.ascending = k.default_ascending(); } } }); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("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, crate::i18n::tr("Grab bag")).clicked() { v.table = false; } - if ui.selectable_label(v.table, "Table").clicked() { + if ui.selectable_label(v.table, crate::i18n::tr("Table")).clicked() { v.table = true; } }); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); - ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); + ui.label( + RichText::new(crate::i18n::format("{} contributors · {} commits", &[(list.len()).to_string(), (group(TOTAL_COMMITS)).to_string()])) + .small() + .weak(), + ); ui.separator(); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { if list.is_empty() { - ui.label("No contributor data was built into this copy."); + ui.label(crate::i18n::tr("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v); } else { @@ -268,7 +274,11 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() { + if ui + .button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong()) + .on_hover_text(crate::i18n::tr(k.label().0)) + .clicked() + { if v.key == k { v.ascending = !v.ascending; } else { @@ -297,14 +307,14 @@ 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(crate::i18n::tr("No model credits were built into this copy.")); return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { - ui.label(RichText::new(h).strong()); + ui.label(RichText::new(crate::i18n::tr(h)).strong()); } ui.end_row(); for m in MODELS { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 73ce180b4..affbd6d55 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -64,7 +64,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; - egui::Window::new(title(&d.id)) + egui::Window::new(crate::i18n::tr(title(&d.id))) .id(egui::Id::new(("dialog", d.id.clone()))) .collapsible(false) .resizable(false) @@ -85,12 +85,14 @@ 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(crate::i18n::tr(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(crate::i18n::tr("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { cancel = true; } }); @@ -100,7 +102,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| { - ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label)); + ui.add_sized(vec2(120.0, 20.0), egui::Label::new(crate::i18n::tr(label))); ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)); }); d.fields.insert(key.into(), v); @@ -108,7 +110,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, crate::i18n::tr(label)); d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); v } @@ -135,17 +137,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "slideSize" => { let cur = app.session.active().map(|s| s.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); - ui.label("Slides sized for:"); + ui.label(crate::i18n::tr("Slides sized for:")); let preset = d.get("preset", ""); - egui::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 { - if ui.selectable_label(preset == *label, *label).clicked() { - d.fields.insert("preset".into(), label.to_string()); - d.fields.insert("w".into(), format!("{:.2}", w / 72.0)); - d.fields.insert("h".into(), format!("{:.2}", h / 72.0)); + egui::ComboBox::from_id_salt("sizes").selected_text(crate::i18n::tr(if preset.is_empty() { "Custom" } else { &preset })).show_ui( + ui, + |ui| { + for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { + if ui.selectable_label(preset == *label, crate::i18n::tr(label)).clicked() { + d.fields.insert("preset".into(), label.to_string()); + d.fields.insert("w".into(), format!("{:.2}", w / 72.0)); + d.fields.insert("h".into(), format!("{:.2}", h / 72.0)); + } } - } - }); + }, + ); field(ui, d, "Width (in):", "w", &format!("{:.2}", cur.width / 72.0)); field(ui, d, "Height (in):", "h", &format!("{:.2}", cur.height / 72.0)); let maximize = check(ui, d, "Maximize content (else ensure fit)", "max", false); @@ -168,9 +173,9 @@ fn body(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(crate::i18n::tr("Apply to All")).clicked(); + apply = ui.button(crate::i18n::tr("Apply")).clicked(); + if ui.button(crate::i18n::tr("Cancel")).clicked() { d.fields.insert("cancel".into(), "1".into()); } }); @@ -188,13 +193,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "hyperlink" => { let mode = d.get("mode", "url"); ui.horizontal(|ui| { - if ui.selectable_label(mode == "url", "Web Page or File").clicked() { + if ui.selectable_label(mode == "url", crate::i18n::tr("Web Page or File")).clicked() { d.fields.insert("mode".into(), "url".into()); } - if ui.selectable_label(mode == "slide", "This Document").clicked() { + if ui.selectable_label(mode == "slide", crate::i18n::tr("This Document")).clicked() { d.fields.insert("mode".into(), "slide".into()); } - if ui.selectable_label(mode == "email", "Email Address").clicked() { + if ui.selectable_label(mode == "email", crate::i18n::tr("Email Address")).clicked() { d.fields.insert("mode".into(), "email".into()); } }); @@ -233,13 +238,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "setupShow" => { 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(crate::i18n::tr("Show type")); for (l, v) in [ ("Presented by a speaker (full screen)", "speaker"), ("Browsed by an individual (window)", "browsed"), ("Browsed at a kiosk (full screen)", "kiosk"), ] { - if ui.radio(ty == v, l).clicked() { + if ui.radio(ty == v, crate::i18n::tr(l)).clicked() { d.fields.insert("type".into(), v.into()); } } @@ -261,21 +266,31 @@ fn body(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 { + format!("“{}”", names[0]) + } else { + crate::i18n::count(names.len(), "presentations", "презентації", "презентацій", "презентацій") + }; + ui.label( + egui::RichText::new(crate::i18n::format("Do you want to save the changes you made to {what}?", std::slice::from_ref(&what))).strong(), + ); + ui.label(crate::i18n::tr("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(crate::i18n::tr("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(crate::i18n::tr("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; @@ -283,7 +298,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } done = true; } - if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() { + if ui.add(egui::Button::new(crate::i18n::tr("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { done = true; } }); @@ -296,7 +311,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = app.run("view.zoom", json!({"percent": pct})); } } - if ui.button("Fit").clicked() { + if ui.button(crate::i18n::tr("Fit")).clicked() { app.ui.zoom = None; } let (ok, cancel) = buttons(ui, "OK"); @@ -304,7 +319,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "comment" => { let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Start a conversation").desired_rows(4).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text(crate::i18n::tr("Start a conversation")).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() { @@ -315,7 +330,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "altText" => { let cur = app.session.active().and_then(|s| s.selected_shapes().first().map(|x| x.descr.clone())).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(crate::i18n::tr("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).desired_rows(4).desired_width(340.0)); d.fields.insert("text".into(), v.clone()); @@ -327,7 +342,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "outline" => { - ui.label("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets."); + ui.label(crate::i18n::tr("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).desired_rows(10).desired_width(380.0).code_editor()); d.fields.insert("text".into(), v.clone()); @@ -368,7 +383,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "export" => { - ui.label("Export the presentation as:"); + ui.label(crate::i18n::tr("Export the presentation as:")); let fmt = d.get("fmt", "pdf"); for (l, f) in [ ("PDF document (.pdf)", "pdf"), @@ -378,7 +393,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("PowerPoint Presentation (.pptx)", "pptx"), ("Outline (.txt)", "outline"), ] { - if ui.radio(fmt == f, l).clicked() { + if ui.radio(fmt == f, crate::i18n::tr(l)).clicked() { d.fields.insert("fmt".into(), f.into()); } } @@ -386,7 +401,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if fmt == "pdf" { ui.add_space(4.0); ui.horizontal(|ui| { - ui.label("Print layout:"); + ui.label(crate::i18n::tr("Print layout:")); egui::ComboBox::from_id_salt("pdf_layout") .selected_text(match pdf_layout.as_str() { "notes" => "Notes Pages", @@ -409,14 +424,14 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("h6", "Handouts (6 slides per page)"), ("h9", "Handouts (9 slides per page)"), ] { - if ui.selectable_label(pdf_layout == k, l).clicked() { + if ui.selectable_label(pdf_layout == k, crate::i18n::tr(l)).clicked() { d.fields.insert("pdfLayout".into(), k.into()); } } }); }); let mut hidden = d.get("pdfHidden", "false") == "true"; - if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + if ui.checkbox(&mut hidden, crate::i18n::tr("Include hidden slides")).changed() { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } @@ -450,7 +465,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } let r = app.session.execute("file.export", ¶ms); match r { - Ok(_) => app.set_status(format!("Exported {path}")), + Ok(_) => app.set_status(crate::i18n::format("Exported {path}", std::slice::from_ref(&path))), Err(e) => app.set_status(e.to_string()), } } @@ -473,7 +488,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "symbol" => { - ui.label("Click a symbol to insert it at the insertion point."); + ui.label(crate::i18n::tr("Click a symbol to insert it at the insertion point.")); let groups: [(&str, &str); 6] = [ ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), ("Currency", "$€£¥¢₹₩₽₺₿"), @@ -483,7 +498,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("Shapes", "■□▪▫▲△▶▷▼▽◀◁◆◇○●◎★☆✓✔✗✘♠♣♥♦"), ]; for (g, chars) in groups { - ui.label(egui::RichText::new(g).font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr(g)).font(theme::bold(12.0))); ui.horizontal_wrapped(|ui| { for c in chars.chars() { if ui.add(egui::Button::new(egui::RichText::new(c.to_string()).size(16.0)).min_size(vec2(26.0, 26.0))).clicked() { @@ -499,7 +514,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "equation" => { - ui.label("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a)."); + ui.label(crate::i18n::tr("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).desired_width(340.0).font(theme::font(15.0))); d.fields.insert("eq".into(), v.clone()); @@ -519,13 +534,16 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let v = app.session.execute("review.spelling", &json!({})).unwrap_or_default(); let list = v.as_array().cloned().unwrap_or_default(); if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); + ui.label(crate::i18n::tr("The spelling check is complete. No issues found.")); } for item in &list { ui.horizontal(|ui| { let w = item.get("word").and_then(Value::as_str).unwrap_or(""); - ui.label(format!("Slide {}: “{w}”", item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { + ui.label(crate::i18n::format( + "Slide {}: “{w}”", + &[(item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1).to_string(), (w).to_string()], + )); + if ui.small_button(crate::i18n::tr("Go to")).clicked() { let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); } }); @@ -537,18 +555,22 @@ fn body(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(crate::i18n::tr( + "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 { ui.horizontal(|ui| { - ui.label(format!( + ui.label(crate::i18n::format( "Slide {}: {}{}", - item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, - 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() + &[ + (item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1).to_string(), + (item.get("issue").and_then(Value::as_str).unwrap_or("")).to_string(), + (item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default()).to_string(), + ], )); - if ui.small_button("Go to").clicked() { + if ui.small_button(crate::i18n::tr("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]})); @@ -561,7 +583,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "language" => { - ui.label("Mark selected text as:"); + ui.label("Українська"); + ui.label(crate::i18n::tr("Mark selected text as:")); for l in [ "English (United States)", "English (United Kingdom)", @@ -574,7 +597,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "中文", "العربية", ] { - let _ = ui.selectable_label(l.starts_with("English (United States)"), l); + let _ = ui.selectable_label(l.starts_with("English (United States)"), crate::i18n::tr(l)); } let (ok, cancel) = buttons(ui, "OK"); ok || cancel @@ -583,8 +606,12 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let shows = app.session.active().map(|s| s.doc.custom_shows.clone()).unwrap_or_default(); for s in &shows { ui.horizontal(|ui| { - ui.label(format!("{} ({} slides)", s.name, s.slides.len())); - if ui.small_button("Delete").clicked() { + ui.label(format!( + "{} ({})", + s.name, + crate::i18n::count(s.slides.len(), if s.slides.len() == 1 { "slide" } else { "slides" }, "слайд", "слайди", "слайдів") + )); + if ui.small_button(crate::i18n::tr("Delete")).clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); @@ -606,11 +633,11 @@ fn body(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(crate::i18n::tr("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(crate::i18n::tr("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() { @@ -631,11 +658,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); ui.horizontal(|ui| { - if ui.button("+ Row").clicked() { + if ui.button(crate::i18n::tr("+ Row")).clicked() { let n = grid.first().map(|r| r.len()).unwrap_or(2); grid.push(vec![String::new(); n]); } - if ui.button("+ Series").clicked() { + if ui.button(crate::i18n::tr("+ Series")).clicked() { for r in grid.iter_mut() { r.push(String::new()); } @@ -667,7 +694,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok || cancel } "smartart" => { - ui.label("Pick a graphic; type one item per line."); + ui.label(crate::i18n::tr("Pick a graphic; type one item per line.")); let kind = d.get("kind", "process"); ui.horizontal_wrapped(|ui| { for (k, l) in [ @@ -678,7 +705,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("pyramid", "Pyramid"), ("matrix", "Matrix"), ] { - if ui.selectable_label(kind == k, l).clicked() { + if ui.selectable_label(kind == k, crate::i18n::tr(l)).clicked() { d.fields.insert("kind".into(), k.into()); } } @@ -696,7 +723,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let tab = d.get("tab", "about"); ui.horizontal(|ui| { for (k, l) in [("about", "About"), ("contributors", "Contributors"), ("models", "Models")] { - if ui.selectable_label(tab == k, l).clicked() { + if ui.selectable_label(tab == k, crate::i18n::tr(l)).clicked() { d.fields.insert("tab".into(), k.into()); } } @@ -715,29 +742,44 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ok } "preferences" => { - ui.label(egui::RichText::new("General").font(theme::bold(13.0))); + ui.horizontal(|ui| { + ui.label(crate::i18n::tr("Interface language:")); + egui::ComboBox::from_id_salt("interface_language").selected_text(app.ui.interface_language.label()).show_ui(ui, |ui| { + for language in crate::i18n::Language::ALL { + if ui.selectable_label(app.ui.interface_language == language, crate::i18n::tr(language.label())).clicked() { + let code = match language { + crate::i18n::Language::System => "system", + crate::i18n::Language::En => "en", + crate::i18n::Language::Uk => "uk", + }; + let _ = app.run("app.language", json!({"language": code})); + } + } + }); + }); + ui.label(egui::RichText::new(crate::i18n::tr("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, crate::i18n::tr("Dark appearance")).changed() { let _ = app.run("view.dark", json!({"on": dark})); } let mut scale = app.ui.ui_scale; - if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() { + if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text(crate::i18n::tr("Interface size"))).changed() { app.ui.ui_scale = scale; } - 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.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + ui.label(egui::RichText::new(crate::i18n::tr("Editing")).font(theme::bold(13.0))); + ui.checkbox(&mut app.session.prefs.smart_guides, crate::i18n::tr("Show smart guides")); + ui.checkbox(&mut app.session.prefs.snap_to_grid, crate::i18n::tr("Snap objects to grid")); + ui.checkbox(&mut app.session.prefs.smart_quotes, crate::i18n::tr("Replace straight quotes with smart quotes")); + ui.checkbox(&mut app.session.prefs.autocorrect, crate::i18n::tr("AutoCorrect as you type")); ui.horizontal(|ui| { - ui.label("Your name (comments):"); + ui.label(crate::i18n::tr("Your name (comments):")); ui.text_edit_singleline(&mut app.session.prefs.author); }); let (ok, _) = buttons(ui, "Done"); ok } _ => { - ui.label(format!("“{}” is not available yet.", d.id)); + ui.label(crate::i18n::format("“{}” is not available yet.", std::slice::from_ref(&d.id))); let (ok, cancel) = buttons(ui, "OK"); ok || cancel } @@ -791,17 +833,21 @@ 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(crate::i18n::tr("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(crate::i18n::tr("Presentations and slide shows, rebuilt from scratch in pure Rust.")); + ui.label(crate::i18n::tr("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(crate::i18n::tr("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.hyperlink_to(crate::i18n::tr("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(crate::i18n::tr("MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors")) + .size(11.0) + .color(t.text_faint), + ); }); } @@ -809,7 +855,7 @@ pub fn about(_app: &mut SlideApp, ui: &mut Ui) { /// from `[workspace.package] version` (the single source of truth, `cargo xtask version`); the /// release workflow sets `DECKCRAFT_BUILD_SHA` / `DECKCRAFT_BUILD_DATE` at build time. pub fn version_line() -> String { - let mut s = format!("Version {}", env!("CARGO_PKG_VERSION")); + let mut s = crate::i18n::format("Version {}", &[(env!("CARGO_PKG_VERSION")).to_string()]); let sha = option_env!("DECKCRAFT_BUILD_SHA").map(|s| s.get(..9).unwrap_or(s)).filter(|s| !s.is_empty()); let date = option_env!("DECKCRAFT_BUILD_DATE").filter(|s| !s.is_empty()); match (sha, date) { @@ -845,7 +891,7 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(40.0); let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); paint_logo(ui.painter(), r); - ui.label(egui::RichText::new("DeckCraft").font(theme::bold(26.0))); + ui.label(egui::RichText::new(crate::i18n::tr("DeckCraft")).font(theme::bold(26.0))); }); if app.home_open() { let title = app.session.active().map(|d| d.title()).unwrap_or_default(); @@ -860,7 +906,7 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(16.0); ui.horizontal(|ui| { ui.add_space(40.0); - ui.label(egui::RichText::new("New presentation").font(theme::bold(16.0))); + ui.label(egui::RichText::new(crate::i18n::tr("New presentation")).font(theme::bold(16.0))); }); ui.add_space(8.0); inset_wrapped(ui, |ui| { @@ -880,10 +926,10 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(16.0); ui.horizontal(|ui| { ui.add_space(40.0); - if ui.button("Open…").clicked() { + if ui.button(crate::i18n::tr("Open…")).clicked() { let _ = app.run("app.openDialog", json!({})); } - if ui.button("Open the sample deck").clicked() + if ui.button(crate::i18n::tr("Open the sample deck")).clicked() && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) { app.set_status(e.to_string()); @@ -893,7 +939,7 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(16.0); ui.horizontal(|ui| { ui.add_space(40.0); - ui.label(egui::RichText::new("Recent").font(theme::bold(16.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Recent")).font(theme::bold(16.0))); }); for p in app.ui.recent.clone() { ui.horizontal(|ui| { @@ -909,7 +955,7 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(30.0); ui.horizontal(|ui| { ui.add_space(40.0); - ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to(crate::i18n::tr("Join the ArtCraft community on Discord"), deckcraft_engine::links::DISCORD); }); }); } diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index 7f0f7d197..0fe0c1feb 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -109,7 +109,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)).min(g.stops.len().saturating_sub(1)); // Preset variations of the first stop's colour. - ui.label("Preset gradients"); + ui.label(crate::i18n::tr("Preset gradients")); ui.horizontal_wrapped(|ui| { let base = g.stops.first().map(|s| s.color.clone()).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)); let mut light = base.clone(); @@ -165,7 +165,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> GradientShape::Path { .. } => "Radial", }; ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Type"))); egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { for k in ["Linear", "Radial", "Rectangular", "Path"] { if ui.selectable_label(kind == k, k).clicked() && kind != k { @@ -185,12 +185,12 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> }); }); ui.horizontal_wrapped(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Direction"))); match &g.shape { GradientShape::Linear { angle, .. } => { for (l, a) in DIRECTIONS_LINEAR { let on = (angle - a).abs() < 0.5; - if ui.selectable_label(on, l).on_hover_text(format!("{a}°")).clicked() { + if ui.selectable_label(on, crate::i18n::tr(l)).on_hover_text(format!("{a}°")).clicked() { out = Some(Gradient { shape: GradientShape::Linear { angle: a, scaled: false }, ..g.clone() }); } } @@ -198,7 +198,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> GradientShape::Path { path, focus } => { for (l, f) in DIRECTIONS_PATH { let on = focus.iter().zip(f).all(|(a, b)| (a - b).abs() < 1e-3); - if ui.selectable_label(on, l.trim_start_matches("From ")).on_hover_text(l).clicked() { + if ui.selectable_label(on, l.trim_start_matches("From ")).on_hover_text(crate::i18n::tr(l)).clicked() { out = Some(Gradient { shape: GradientShape::Path { path: path.clone(), focus: f }, ..g.clone() }); } } @@ -207,7 +207,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> }); if let GradientShape::Linear { angle, .. } = &g.shape { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Angle")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Angle"))); if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), *angle, 15.0, "°", 70.0) { out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() }); } @@ -216,7 +216,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // Stop bar: click to add, drag a stop to move it. ui.add_space(4.0); - ui.label("Gradient stops"); + ui.label(crate::i18n::tr("Gradient stops")); let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click()); paint_bar(ui, bar, g, sc); let (marks, _) = ui.allocate_exact_size(vec2(bar.width(), 14.0), Sense::hover()); @@ -272,7 +272,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // The selected stop. if let Some(s) = g.stops.get(sel) { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Color")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Color"))); let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true); @@ -284,7 +284,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); let can_remove = g.stops.len() > 2; - if ui.add_enabled(can_remove, egui::Button::new("Remove stop")).clicked() { + if ui.add_enabled(can_remove, egui::Button::new(crate::i18n::tr("Remove stop"))).clicked() { let mut ng = g.clone(); ng.stops.remove(sel); sel = sel.saturating_sub(1); @@ -292,7 +292,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Position")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Position"))); if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), (s.pos * 100.0).round(), 5.0, "%", 70.0) { let mut ng = g.clone(); ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0); @@ -301,7 +301,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Transparency"))); let key = ui.id().with((id, "tr", sel)); let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32)); let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 000000000..361bf07fb --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,358 @@ +//! Interface text only: presentation content, file names and command identifiers stay intact. + +use std::cell::Cell; +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Language { + #[default] + System, + En, + Uk, +} + +impl Language { + pub const ALL: [Self; 3] = [Self::System, Self::En, Self::Uk]; + + pub fn label(self) -> &'static str { + match self { + Self::System => tr("System language"), + Self::En => "English", + Self::Uk => "Українська", + } + } + + pub fn from_locale(locale: &str) -> Self { + let language = locale.split(['-', '_', '.', '@']).next().unwrap_or(""); + if language.eq_ignore_ascii_case("uk") { Self::Uk } else { Self::En } + } + + pub fn resolve(self, locale: Option<&str>) -> Self { + if self == Self::System { Self::from_locale(locale.unwrap_or("")) } else { self } + } +} + +thread_local! { + static CURRENT: Cell = const { Cell::new(Language::En) }; +} + +pub fn current() -> Language { + CURRENT.get() +} + +pub fn set_current(language: Language) { + CURRENT.set(language); +} + +const CATALOG: &str = include_str!("i18n/uk.tsv"); +static UK: OnceLock> = OnceLock::new(); + +fn catalog() -> &'static HashMap { + UK.get_or_init(|| { + CATALOG + .lines() + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(|line| line.split_once('\t')) + .map(|(source, text)| (source.replace("\\n", "\n"), text.replace("\\n", "\n"))) + .collect() + }) +} + +/// Exact lookup at an explicit interface boundary; unknown text falls back to English. +pub fn tr(text: &str) -> &str { + if CURRENT.get() == Language::Uk { catalog().get(text).map(String::as_str).unwrap_or(text) } else { text } +} + +/// Context-specific interface label with the original English source as fallback. +pub fn tr_context<'a>(source: &'a str, key: &str) -> &'a str { + if CURRENT.get() == Language::Uk { catalog().get(key).map(String::as_str).unwrap_or(source) } else { source } +} + +pub fn placeholder_prompt(kind: deckcraft_model::PhType) -> &'static str { + tr(kind.prompt()) +} + +/// Substitute already-formatted values without interpreting their contents as interface text. +pub fn format(source: &str, values: &[String]) -> String { + let text = tr(source); + let mut out = String::new(); + let mut rest = text; + let mut index = 0; + while let Some(start) = rest.find('{') { + out.push_str(rest.get(..start).unwrap_or("")); + let tail = rest.get(start + 1..).unwrap_or(""); + if let Some(after) = tail.strip_prefix('{') { + out.push('{'); + rest = after; + } else if let Some(end) = tail.find('}') { + out.push_str(values.get(index).map(String::as_str).unwrap_or("")); + index += 1; + rest = tail.get(end + 1..).unwrap_or(""); + } else { + out.push_str(rest); + return out; + } + } + out.push_str(&rest.replace("}}", "}")); + out +} + +/// Ukrainian integer count forms: 1/21; 2–4/22–24; 0/5–19. +pub fn count(n: usize, english: &str, one: &str, few: &str, many: &str) -> String { + let word = if CURRENT.get() == Language::Uk { + match (n % 10, n % 100) { + (1, last) if last != 11 => one, + (2..=4, last) if !(12..=14).contains(&last) => few, + _ => many, + } + } else { + english + }; + format!("{n} {word}") +} + +/// Recovery notice includes accusative Ukrainian forms. +pub fn recovered(n: usize) -> String { + let amount = count( + n, + if n == 1 { "unsaved presentation" } else { "unsaved presentations" }, + "незбережену презентацію", + "незбережені презентації", + "незбережених презентацій", + ); + format("Recovered {} from the last session", &[amount]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn placeholders(text: &str) -> Vec<&str> { + let mut out = vec![]; + let mut rest = text; + while let Some((_, tail)) = rest.split_once('{') { + if let Some(tail) = tail.strip_prefix('{') { + rest = tail; + continue; + } + if let Some((field, tail)) = tail.split_once('}') { + out.push(field); + rest = tail; + } else { + break; + } + } + out + } + + #[test] + fn all_command_menu_and_gallery_labels_are_translated() { + let mut required = vec![]; + required.extend(crate::UI_COMMANDS.iter().map(|c| c.1)); + required.extend(deckcraft_engine::Session::new().commands().into_iter().map(|c| c.label)); + for (menu, items) in crate::menus::menu_tree() { + required.push(menu); + required.extend(items.into_iter().filter(|(label, _)| *label != "-").map(|(label, _)| label)); + } + required.extend(deckcraft_geom::preset::CATALOG.iter().map(|shape| shape.label)); + required.extend(deckcraft_geom::preset::Category::ALL.iter().map(|category| category.label())); + required.extend(deckcraft_model::anim::ANIMATIONS.iter().map(|effect| effect.1)); + required.extend(deckcraft_model::anim::TRANSITIONS.iter().flat_map(|effect| [effect.1, effect.2])); + required.extend(deckcraft_model::chart::ChartType::MAIN.iter().map(|kind| kind.label())); + required.extend(deckcraft_model::defaults::SLIDE_SIZES.iter().map(|size| size.0)); + required.extend(deckcraft_color::STANDARD_COLORS.iter().map(|color| color.1)); + required.extend(deckcraft_color::SchemeSlot::THEME.iter().map(|slot| slot.label())); + required.extend(["@ribbon:Home", "@ribbon:Insert"]); + for option in deckcraft_model::anim::TRANSITIONS + .iter() + .flat_map(|effect| effect.4) + .chain(deckcraft_model::anim::ANIMATIONS.iter().flat_map(|effect| effect.5)) + { + let label = crate::ribbon::option_label(option); + if !label.is_empty() && !label.chars().all(|c| c.is_ascii_digit()) { + assert!(catalog().contains_key(&label), "missing effect option: {option}: {label}"); + } + } + let missing: Vec<_> = required.into_iter().filter(|key| !catalog().contains_key(*key)).collect(); + assert!(missing.is_empty(), "missing visible metadata: {missing:?}"); + } + + #[test] + fn every_literal_translation_call_has_a_catalog_entry() { + for source in [ + include_str!("canvas.rs"), + include_str!("credits.rs"), + include_str!("dialogs.rs"), + include_str!("fillui.rs"), + include_str!("lib.rs"), + include_str!("media.rs"), + include_str!("menus.rs"), + include_str!("panes.rs"), + include_str!("ribbon.rs"), + include_str!("show.rs"), + include_str!("sorter.rs"), + include_str!("status.rs"), + include_str!("thumbs.rs"), + include_str!("widgets.rs"), + include_str!("../../../apps/deckcraft/src/main.rs"), + include_str!("../../../apps/deckcraft-web/src/web.rs"), + ] { + for prefix in ["i18n::tr(\"", "i18n::format(\""] { + let mut rest = source.split("#[cfg(test)]").next().unwrap_or(source); + while let Some((_, tail)) = rest.split_once(prefix) { + let mut escaped = false; + let end = tail + .char_indices() + .find_map(|(index, c)| { + if escaped { + escaped = false; + None + } else if c == '\\' { + escaped = true; + None + } else if c == '\"' { + Some(index) + } else { + None + } + }) + .unwrap(); + let key: String = serde_json::from_str(&format!("\"{}\"", &tail[..end])).unwrap(); + assert!(catalog().contains_key(&key), "missing literal translation: {key}"); + rest = &tail[end + 1..]; + } + } + } + } + + #[test] + fn language_command_keeps_document_data_and_identifiers_intact() { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("insert.textBox", serde_json::json!({"text": "File {customer} / Звіт", "rect": [1, 2, 100, 40]})).unwrap(); + let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + app.run("app.language", serde_json::json!({"language": "uk"})).unwrap(); + assert_eq!(app.ui.interface_language, Language::Uk); + assert_eq!(tr("File"), "Файл"); + assert_eq!(before, serde_json::to_value(&app.session.active().unwrap().doc).unwrap()); + assert!(app.run("app.language", serde_json::json!({"language": "invalid"})).is_err()); + assert_eq!(app.ui.interface_language, Language::Uk); + set_current(Language::En); + } + + #[test] + fn bundled_ui_fonts_cover_the_ukrainian_alphabet_without_system_fonts() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into()), egui::FontFamily::Monospace] { + // egui 0.36 has_glyph() can report a valid fallback face as a replacement. + // Check the configured fallback and its actual font charmap below instead. + let fallback_installed = ui.ctx().fonts_mut(|fonts| { + fonts.definitions().families.get(&family).is_some_and(|names| names.iter().any(|name| name == "Ubuntu-Light")) + }); + assert!(fallback_installed, "missing bundled Ukrainian fallback in {family:?}"); + } + }); + output.textures_delta.clear(); + let db = deckcraft_fonts::FontDb::with_font_dirs(vec![]); + let face = db.face(deckcraft_fonts::LAST_RESORT_FAMILY, "Regular"); + let letters: std::collections::HashSet<_> = + catalog().values().flat_map(|value| value.chars()).chain("ҐґЄєІіЇї".chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect(); + for c in letters { + assert_ne!(face.glyph_for(c), 0, "missing document fallback glyph: {c}"); + } + } + + // An explicit local visual check; the generated PNGs stay outside the repository. + #[test] + #[ignore = "writes translation review screenshots; run explicitly with DECKCRAFT_I18N_SHOTS"] + fn ukrainian_interface_preview() { + let dir = std::env::var("DECKCRAFT_I18N_SHOTS").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + for (code, language) in [("en", Language::En), ("uk", Language::Uk)] { + for (name, dialog) in [("editor", None), ("preferences", Some("preferences")), ("table", Some("table"))] { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.ui.interface_language = language; + app.dialog = dialog.map(crate::dialogs::Dialog::new); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui(|ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + harness.run_steps(6); + harness.render().unwrap().save(format!("{dir}/{code}-{name}.png")).unwrap(); + } + } + set_current(Language::En); + } + + #[test] + fn locales_and_preferences_are_backward_compatible() { + for locale in ["uk", "uk-UA", "uk_UA.UTF-8", "UK_ua@euro"] { + assert_eq!(Language::from_locale(locale), Language::Uk); + } + for locale in ["", "en-US", "ru-UA", "ukrainian"] { + assert_eq!(Language::from_locale(locale), Language::En); + } + assert_eq!(Language::Uk.resolve(Some("en")), Language::Uk); + let old: crate::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(old.interface_language, Language::System); + let mut ui = old; + ui.interface_language = Language::Uk; + let round_trip: crate::UiState = serde_json::from_str(&serde_json::to_string(&ui).unwrap()).unwrap(); + assert_eq!(round_trip.interface_language, Language::Uk); + } + + #[test] + fn catalog_is_unique_complete_and_preserves_placeholders() { + let mut seen = std::collections::HashSet::new(); + for (line_no, line) in CATALOG.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let (source, text) = line.split_once('\t').unwrap(); + assert!(seen.insert(source), "duplicate on {}: {source}", line_no + 1); + assert!(!text.trim().is_empty(), "empty translation: {source}"); + assert_eq!(placeholders(source), placeholders(text), "placeholder names/order/format: {source}"); + } + } + + #[test] + fn lookup_and_format_preserve_user_values_and_fallbacks() { + set_current(Language::Uk); + assert_eq!(tr("File"), "Файл"); + assert_eq!(tr_context("Insert", "@ribbon:Insert"), "Вставлення"); + assert_eq!(tr("Insert"), "Вставити"); + assert_eq!(tr("a user-defined slide title"), "a user-defined slide title"); + let user = "File {user} / Звіт.pptx".to_string(); + assert!(format("Saved {path}", std::slice::from_ref(&user)).ends_with(&user)); + set_current(Language::En); + assert_eq!(tr("File"), "File"); + } + + #[test] + fn ukrainian_integer_counts_cover_teens_and_large_values() { + set_current(Language::Uk); + for (n, word) in [ + (0, "слайдів"), + (1, "слайд"), + (2, "слайди"), + (4, "слайди"), + (5, "слайдів"), + (11, "слайдів"), + (12, "слайдів"), + (14, "слайдів"), + (21, "слайд"), + (24, "слайди"), + (111, "слайдів"), + ] { + assert_eq!(count(n, "slides", "слайд", "слайди", "слайдів"), format!("{n} {word}")); + } + set_current(Language::En); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv new file mode 100644 index 000000000..1c20bce7b --- /dev/null +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -0,0 +1,1234 @@ +# Ukrainian interface catalog. English sourcetranslation; \n denotes a line break. +# Contributor-original translation, MIT OR Apache-2.0. + Fixed date (empty = update automatically): Фіксована дата (порожнє поле = автоматичне оновлення): + Footer text: Текст нижнього колонтитула: + Present Показати + pt пт + — Edited — Змінено +"Not Allowed" Symbol Знак «Не дозволено» +% of all commits % усіх комітів ++ New Comment + Новий коментар ++ New Slide + Новий слайд ++ Row + Рядок ++ Series + Ряд даних ++Bin +Бін +100% Stacked Bar Нормована лінійчата з накопиченням +100% Stacked Column Нормована стовпчаста з накопиченням +35mm Slides Слайди 35 мм +@ribbon:Home Основне +@ribbon:Insert Вставлення +@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {} @{}: запити на злиття: {}, коміти: {}, рядки: +{} / −{} (Δ {}), двійкові ресурси: +{} / −{}, {} – {} +A3 Paper (297x420 mm) Папір A3 (297×420 мм) +A4 Paper (210x297 mm) Папір A4 (210×297 мм) +Aa Aa +Abc Abc +About Про програму +About DeckCraft Про DeckCraft +Accent 1 Акцент 1 +Accent 2 Акцент 2 +Accent 3 Акцент 3 +Accent 4 Акцент 4 +Accent 5 Акцент 5 +Accent 6 Акцент 6 +Accessibility Checker Перевірка доступності +Across Поперек +Action Дія +Action Button: Custom Кнопка дії: Власна +Action Button: Document Кнопка дії: Документ +Action Button: Get Information Кнопка дії: Отримати відомості +Action Button: Go Back Кнопка дії: Повернутися +Action Button: Go Back or Previous Кнопка дії: Назад або до попереднього +Action Button: Go Forward or Next Кнопка дії: Уперед або до наступного +Action Button: Go Home Кнопка дії: На початок +Action Button: Go to Beginning Кнопка дії: До початку +Action Button: Go to End Кнопка дії: До кінця +Action Button: Help Кнопка дії: Довідка +Action Button: Sound Кнопка дії: Звук +Action Button: Video Кнопка дії: Відео +Action Button: {k} Кнопка дії: {k} +Action Buttons Кнопки дій +Add Animation Додати анімацію +Add Section Додати розділ +Add\nAnimation Додати анімацію +Address: Адреса: +Adjust Shape Змінити фігуру +After Previous Після попереднього +After: Після: +Airplane Літак +Align Вирівняти +Align Bottom Вирівняти за нижнім краєм +Align Center Вирівняти по центру +Align Left Вирівняти за лівим краєм +Align Middle Вирівняти посередині +Align Right Вирівняти за правим краєм +Align Text Вирівняти текст +Align Top Вирівняти за верхнім краєм +Alt Text Альтернативний текст +Alt+ Alt+ +Alt+F9 Alt+F9 +Alt\nText Альтернативний текст +An open-source, clean-room project. Not affiliated with Microsoft. Незалежний проєкт із відкритим кодом. Не пов’язаний із Microsoft. +Angle Кут +Animation Gallery Галерея анімації +Animation Pane Панель анімації +Animation Styles Стилі анімації +Animation\nPane Панель анімації +Animations Анімації +Animation Painter Копіювання анімації +Apply Animation Застосувати анімацію +Play/Pause GIF Відтворити/призупинити GIF +Animations on Slide Анімації на слайді +Appear Поява +Apply Застосувати +Apply To All Застосувати до всіх +Apply to All Застосувати до всіх +Apply\nTo All Застосувати до всіх +Arc Дуга +Arcs Дуги +Area З областями +Arrange Упорядкувати +Arrow Стрілка +Arrow at end Стрілка в кінці +Arrow at start Стрілка на початку +Arrow: Bent Стрілка: Зігнута +Arrow: Bent-Up Стрілка: Зігнута вгору +Arrow: Chevron Стрілка: Шеврон +Arrow: Down Стрілка: Униз +Arrow: Left Стрілка: Ліворуч +Arrow: Left-Right Стрілка: Ліворуч і праворуч +Arrow: Left-Right-Up Стрілка: Ліворуч, праворуч і вгору +Arrow: Left-Up Стрілка: Ліворуч і вгору +Arrow: Notched Right Стрілка: Праворуч із вирізом +Arrow: Pentagon Стрілка: П’ятикутник +Arrow: Quad Стрілка: У чотири боки +Arrow: Right Стрілка: Праворуч +Arrow: Striped Right Стрілка: Смугаста праворуч +Arrow: U-Turn Стрілка: Розворот +Arrow: Up Стрілка: Угору +Arrow: Up-Down Стрілка: Угору й униз +Arrows Стрілки +Arrows at both ends Стрілки на обох кінцях +As One Object Як один об’єкт +Audio Аудіо +Audio from File… Аудіо з файлу… +AutoCorrect as you type Автовиправлення під час введення +Autofit Автоматичний добір +Automatic Автоматично +B4 (ISO) Paper (250x353 mm) Папір B4 (ISO) (250×353 мм) +B5 (ISO) Paper (176x250 mm) Папір B5 (ISO) (176×250 мм) +Background Тло +Background Styles Стилі тла +Background\nStyles Стилі тла +Backspace Backspace +Banded Columns Смугасті стовпці +Banded Rows Смугасті рядки +Banner Стрічка +Basic Cycle Простий цикл +Basic List Простий список +Basic Process Простий процес +Basic Shapes Основні фігури +Before text (in): Перед текстом (дюйми): +Bevel Скіс +Binary assets added Додані двійкові ресурси +Binary assets removed Видалені двійкові ресурси +Black Left Через чорний ліворуч +Black Pen Чорне перо +Black Right Через чорний праворуч +Blinds Жалюзі +Block Arc Блокова дуга +Block Arrows Блокові стрілки +Blue Синій +Blue Pen Синє перо +Bold Напівжирний +Bold Flash Спалах напівжирного +Both Обидва +Bottom Низ +Bounce Відскок +Box Коробка +Box In Коробка всередину +Box Out Коробка назовні +Box and Whisker Ящик із вусами +Brightness Яскравість +Brightness {:+}% Яскравість {:+}% +Bring Forward Перемістити вперед +Bring to Front На передній план +Bring\nForward Перемістити вперед +Browsed at a kiosk (full screen) Перегляд у кіоску (повноекранний) +Browsed by an individual (window) Індивідуальний перегляд (у вікні) +Bubble Бульбашкова +Bullets Маркери +Burst Спалах +By Paragraph За абзацами +Callout: Down Arrow Виноска: Стрілка вниз +Callout: Left Arrow Виноска: Стрілка ліворуч +Callout: Line Виноска: Лінія +Callout: Right Arrow Виноска: Стрілка праворуч +Callout: Up Arrow Виноска: Стрілка вгору +Callouts Виноски +Cameo Камео +Camera capture is not available yet Захоплення з камери ще недоступне +Cancel Скасувати +Capitalize Each Word Кожне слово з великої літери +Caps Великі літери +Center По центру +Centered По центру +Change Case Змінити регістр +Change Chart Type Змінити тип діаграми +Change Picture Змінити зображення +Change Shape Змінити фігуру +Change\nChart Type Змінити тип діаграми +Character Spacing Міжсимвольний інтервал +Characters Символи +Chart Діаграма +Chart Data Дані діаграми +Chart Design Оформлення діаграми +Chart Elements Елементи діаграми +Chart… Діаграма… +Check Accessibility Перевірити доступність +Check\nAccessibility Перевірити доступність +Checkerboard Шахова дошка +Chevron Шеврон +Choose a SmartArt Graphic Виберіть графіку SmartArt +Chord Хорда +Circle Коло +Circle In Коло всередину +Circle Out Коло назовні +Clear All Formatting Очистити все форматування +Click a symbol to insert it at the insertion point. Натисніть символ, щоб вставити його в позиції курсора. +Click icon to add SmartArt graphic Натисніть піктограму, щоб додати графіку SmartArt +Click icon to add chart Натисніть піктограму, щоб додати діаграму +Click icon to add media Натисніть піктограму, щоб додати медіа +Click icon to add picture Натисніть піктограму, щоб додати зображення +Click icon to add table Натисніть піктограму, щоб додати таблицю +Click to add first slide Натисніть, щоб додати перший слайд +Click to add notes Натисніть, щоб додати примітки +Click to add subtitle Натисніть, щоб додати підзаголовок +Click to add text Натисніть, щоб додати текст +Click to add title Натисніть, щоб додати заголовок +Click to edit Master text styles Натисніть, щоб змінити стилі тексту зразка +Click to edit Master title style Натисніть, щоб змінити стиль заголовка зразка +Clock Годинник +Clockwise За годинниковою стрілкою +Close Закрити +Close Master View Закрити режим зразка +Close\nMaster View Закрити режим зразка +Cloud Хмара +Clustered Bar Групована лінійчата +Clustered Column Групована стовпчаста +Cmd+ Cmd+ +Cmd+, Cmd+, +Cmd+- Cmd+- +Cmd+= Cmd+= +Cmd+F1 Cmd+F1 +Cmd+Shift+P Cmd+Shift+P +Collapse the Ribbon Згорнути стрічку +Color Колір +Color Pulse Пульсація кольору +Colors Кольори +Columns Стовпці +Comb Гребінка +Combo Комбінована +Command Palette Палітра команд +Comment Коментар +Comments Коментарі +Commits Коміти +Community Links Посилання спільноти +Company Компанія +Complementary Color Доповняльний колір +Connect Shapes З’єднати фігури +Connection Sites Точки з’єднання +Connector: Curved З’єднувач: кривий +Connector: Elbow З’єднувач: кутовий +Connector: Elbow (one bend) З’єднувач: кутовий (один згин) +Content Вміст +Contrast Контрастність +Contrast {:+}% Контраст {:+}% +Contributors Учасники +Conveyor Конвеєр +Copy Копіювати +Corrections Корекція +Couldn't save AutoRecover information: {e} Не вдалося зберегти дані автоматичного відновлення: {e} +Counter Clockwise Проти годинникової стрілки +Cover Накриття +Create Створити +Crop Обітнути +Crop 10% each side Обітнути на 10% з кожного боку +Crop 20% each side Обітнути на 20% з кожного боку +Crop to Shape Обітнути за фігурою +Cross Хрест +Crush Зминання +Ctrl+ Ctrl+ +Cube Куб +Currency Валюта +Curtains Штори +Custom Довільний +Custom Path Власний шлях +Custom Show Власний показ +Custom Show 1 Власний показ 1 +Custom Shows Власні покази +Custom Slide Show Власний показ слайдів +Custom\nShow Власний показ +Cut Вирізати +Cylinder Циліндр +Dark Blue Темно-синій +Dark Mode Темний режим +Dark Red Темно-червоний +Dark appearance Темне оформлення +Dark\nMode Темний режим +Darken Затемнення +Dash Штрих +Dash Dot Штрих і крапка +Dashes Штрихи +Database База даних +Date Дата +Date & Time Дата й час +Date &\nTime Дата й час +Date and Time Дата й час +Date and time Дата й час +Decagon Десятикутник +Decision Рішення +DeckCraft DeckCraft +Decrease Font Size Зменшити розмір шрифту +Decrease List Level Знизити рівень списку +Delay: Затримка: +Delete Видалити +Delete Columns Видалити стовпці +Delete Comment Видалити коментар +Delete Layout Видалити макет +Delete Rows Видалити рядки +Delete Slide Видалити слайд +Delete Table Видалити таблицю +Delete Text Видалити текст +Desaturate Знебарвити +Deselect Скасувати вибір +Design Оформлення +Design Ideas Ідеї оформлення +Design\nIdeas Ідеї оформлення +Deutsch Deutsch +Diagonal Stripe Діагональна смуга +Diamond Ромб +Diamond In Ромб усередину +Diamond Out Ромб назовні +Direction Напрямок +Disappear Зникнення +Discard Recovered Presentations Відкинути відновлені презентації +Display name Відображуване ім'я +Dissolve Розчинення +Dissolve In Поступова поява +Dissolve Out Поступове зникнення +Distribute Розподілити +Distribute Columns Розподілити стовпці +Distribute Horizontally Розподілити горизонтально +Distribute Rows Розподілити рядки +Distribute Vertically Розподілити вертикально +Division Sign Знак ділення +Do not Autofit Без автоматичного добору +Do you want to save the changes you made to {what}? Зберегти зміни, внесені до {what}? +Document Документ +Dodecagon Дванадцятикутник +Don't Save Не зберігати +Don't show on title slide Не показувати на титульному слайді +Done Готово +Donut Кільце +Doors Двері +Double Brace Подвійна фігурна дужка +Double Bracket Подвійна квадратна дужка +Double Wave Подвійна хвиля +Doughnut Кільцева +Down Униз +Drape Завіса +Draw Малювання +Drop Shadow Rectangle Прямокутник із тінню +Duplicate Дублювати +Duplicate Selected Slides Дублювати вибрані слайди +Duplicate Slide Дублювати слайд +Duration: Тривалість: +Dynamic Content Динамічний вміст +Edit Редагування +Edit Alt Text… Змінити альтернативний текст… +Edit Data Змінити дані +Edit Shape Змінити фігуру +Edit Text Редагувати текст +Edit the data: first row = series names, first column = categories. Змініть дані: перший рядок — назви рядів, перший стовпець — категорії. +Edit\nData Змінити дані +Edit\nShape Змінити фігуру +Editing Редагування +Effect Options Параметри ефекту +Effect\nOptions Параметри ефекту +Effects Ефекти +Email Address Адреса електронної пошти +Email address: Адреса електронної пошти: +Emphasis Виділення +End Кінець +End Show Завершити показ +End of slide show Кінець показу слайдів +End of slide show, click to exit. Кінець показу слайдів. Натисніть, щоб вийти. +End trim (s): Обрізання в кінці (с): +English (United Kingdom) Англійська (Велика Британія) +English (United States) Англійська (США) +Enter Enter +Entrance Поява +Equals Дорівнює +Equation Рівняння +Equation Shapes Фігури рівнянь +Eraser Гумка +Escape Escape +Español Español +Exciting Виразні +Exit Зникнення +Expand Розгорнути +Explosion: 14 Points Спалах: 14 променів +Explosion: 8 Points Спалах: 8 променів +Export Експорт +Export the presentation as: Експортувати презентацію як: +Exported {path} Експортовано: {path} +Export… Експортувати… +Fade Послабити +Fade in (s): Поступова поява (с): +Fade out (s): Поступове зникнення (с): +Fall Over Падіння +Ferris Wheel Оглядове колесо +File Файл +Fill Заливка +Fill & Line Заливка й лінія +Fill Color Колір заливки +Filled Radar Заповнена пелюсткова +Find… Знайти… +First Перший +First Column Перший стовпець +First Slide Перший слайд +First commit Перший коміт +Fit Умістити +Fit slide to current window Підігнати слайд до поточного вікна +Fit to Window Умістити у вікні +Fit to\nWindow Умістити у вікні +Flash Спалах +Flip Віддзеркалити +Flip Horizontal Віддзеркалити горизонтально +Flip Vertical Віддзеркалити вертикально +Float In Плавна поява +Float Out Плавне зникнення +Flowchart Блок-схема +Flowchart: Alternate Process Блок-схема: Альтернативний процес +Flowchart: Card Блок-схема: Картка +Flowchart: Collate Блок-схема: Зіставлення +Flowchart: Connector Блок-схема: З’єднувач +Flowchart: Data Блок-схема: Дані +Flowchart: Decision Блок-схема: Рішення +Flowchart: Delay Блок-схема: Затримка +Flowchart: Direct Access Storage Блок-схема: Сховище прямого доступу +Flowchart: Display Блок-схема: Екран +Flowchart: Document Блок-схема: Документ +Flowchart: Extract Блок-схема: Видобування +Flowchart: Internal Storage Блок-схема: Внутрішнє сховище +Flowchart: Magnetic Disk Блок-схема: Магнітний диск +Flowchart: Manual Input Блок-схема: Ручне введення +Flowchart: Manual Operation Блок-схема: Ручна операція +Flowchart: Merge Блок-схема: Об’єднання +Flowchart: Multidocument Блок-схема: Кілька документів +Flowchart: Off-page Connector Блок-схема: З’єднувач між сторінками +Flowchart: Or Блок-схема: Або +Flowchart: Predefined Process Блок-схема: Визначений процес +Flowchart: Preparation Блок-схема: Підготовка +Flowchart: Process Блок-схема: Процес +Flowchart: Punched Tape Блок-схема: Перфострічка +Flowchart: Sequential Access Storage Блок-схема: Сховище послідовного доступу +Flowchart: Sort Блок-схема: Сортування +Flowchart: Stored Data Блок-схема: Збережені дані +Flowchart: Summing Junction Блок-схема: Вузол підсумовування +Flowchart: Terminator Блок-схема: Початок/кінець +Fly In Влітати +Fly Out Вилітання +Fly Through Пролітання +Folded Corner Загнутий кут +Follow the layout Відповідно до макета +Followed Hyperlink Відвідане гіперпосилання +Font Шрифт +Font Color Колір шрифту +Font Size Розмір шрифту +Fonts Шрифти +Footer Нижній колонтитул +Foreground Передній план +Format Формат +Format Background Формат тла +Format Background… Формат тла… +Format Painter Копіювання формату +Format Painter (double-click to keep it on) Копіювання формату (подвійне натискання для постійного застосування) +Format Pane Панель форматування +Format Shape Формат фігури +Format Shape… Формат фігури… +Format\nBackground Формат тла +Format\nPane Панель форматування +Formatting State Стан форматування +Fracture Розлом +Frame Рамка +Français Français +Freeform Довільна фігура +From Від +From Beginning Від початку +From Bottom Знизу +From Bottom Left Знизу ліворуч +From Bottom Right Знизу праворуч +From Current Slide Від поточного слайда +From Left Зліва +From Right Справа +From Top Згори +From Top Left Згори ліворуч +From Top Right Згори праворуч +From bottom left Знизу ліворуч +From bottom right Знизу праворуч +From center Від центра +From top left Згори ліворуч +From top right Згори праворуч +From\nBeginning Від початку +From\nCurrent Slide Від поточного слайда +Full Повна +Full Page Slides Слайди на всю сторінку +Funnel Лійкова +Gallery Галерея +General Загальні +Get Text Отримати текст +Glitter Блиск +Glow Світіння +Go to Перейти +Go to Slide Перейти до слайда +Grab bag Випадкова добірка +Gradient fill Градієнтна заливка +Gradient stops Опорні точки градієнта +Gradient… Градієнт… +Grayscale Градації сірого +Greek Грецька +Green Зелений +Gridlines Лінії сітки +Group Згрупувати +Group Objects Згрупувати об’єкти +Grow & Turn Збільшення й обертання +Grow/Shrink Збільшення/зменшення +Guides Напрямні +Half Половина +Half Frame Половина рамки +Handout Master Зразок видачі +Handout Master (coming soon) Зразок видачі (незабаром) +Handout\nMaster Зразок видачі +Handouts (1 slide per page) Видача (1 слайд на сторінку) +Handouts (2 slides per page) Видача (2 слайди на сторінку) +Handouts (3 slides per page) Видача (3 слайди на сторінку) +Handouts (4 slides per page) Видача (4 слайди на сторінку) +Handouts (6 slides per page) Видача (6 слайдів на сторінку) +Handouts (9 slides per page) Видача (9 слайдів на сторінку) +Header Верхній колонтитул +Header & Footer Колонтитули +Header &\nFooter Колонтитули +Header Row Рядок заголовка +Header and Footer Колонтитули +Header and Footer… Колонтитули… +Heart Серце +Height Висота +Height (in): Висота (дюйми): +Height: Висота: +Help Довідка +Heptagon Семикутник +Hexagon Шестикутник +Hide All Приховати все +Hide Background Graphics Приховати графіку тла +Hide During Show Приховати під час показу +Hide Slide Приховати слайд +Hide background graphics Приховати графіку тла +Hide\nSlide Приховати слайд +Hierarchy Ієрархія +High Висока +Highlighter Маркер виділення +Histogram Гістограма +Home Початковий екран +Honeycomb Стільники +Horizontal Горизонтально +Horz In Горизонтально всередину +Horz Out Горизонтально назовні +How would you describe this object and its context to someone who is blind or has low vision? Як би ви описали цей об’єкт та його контекст людині з порушенням зору? +Hyperlink Гіперпосилання +Icons Піктограми +In Усередину +In Bounce Усередину з відскоком +Include hidden slides Включити приховані слайди +Increase Font Size Збільшити розмір шрифту +Increase List Level Підвищити рівень списку +Ink to Math Рукописне введення в рівняння +Ink to Math (coming soon) Рукописне введення в рівняння (незабаром) +Ink to Shape Рукописне введення у фігуру +Ink to Shape (coming soon) Рукописне введення у фігуру (незабаром) +Ink to Text Рукописне введення в текст +Ink to Text (coming soon) Рукописне введення в текст (незабаром) +Ink to\nMath Рукописне введення в рівняння +Ink to\nShape Рукописне введення у фігуру +Ink to\nText Рукописне введення в текст +Inner Внутрішня +Insert Вставити +Insert Above Вставити вище +Insert Below Вставити нижче +Insert Hyperlink Вставити гіперпосилання +Insert Layout Вставити макет +Insert Left Вставити ліворуч +Insert Placeholder Вставити заповнювач +Insert Right Вставити праворуч +Insert Table Вставити таблицю +Insert Table… Вставити таблицю… +Insert picture from file… Вставити зображення з файлу… +Insert\nAbove Вставити вище +Insert\nBelow Вставити нижче +Insert\nLayout Вставити макет +Insert\nLeft Вставити ліворуч +Insert\nPlaceholder Вставити заповнювач +Insert\nRight Вставити праворуч +Inspect Presentation Переглянути дані презентації +Inspect Shape Переглянути дані фігури +Inspect Slide Переглянути дані слайда +Interface Language Мова інтерфейсу +Interface language: Мова інтерфейсу: +Interface size Розмір інтерфейсу +Isosceles Triangle Рівнобедрений трикутник +Italiano Italiano +Italic Курсивний +JPEG image (current slide) Зображення JPEG (поточний слайд) +Join our community on Discord Приєднуйтеся до нашої спільноти в Discord +Join the ArtCraft community on Discord Приєднуйтеся до спільноти ArtCraft у Discord +Justify По ширині +L-Shape Г-подібна фігура +Language Мова +Last Останній +Last Column Останній стовпець +Last Slide Останній слайд +Last commit Останній коміт +Layout Макет +Learn More Докладніше +Learn\nMore Докладніше +Ledger Paper (11x17 in) Папір Ledger (11×17 дюймів) +Left Ліворуч +Left Brace Ліва фігурна дужка +Left Bracket Ліва квадратна дужка +Letter Paper (8.5x11 in) Папір Letter (8,5×11 дюймів) +Liberation Serif Liberation Serif +Light Blue Світло-синій +Light Green Світло-зелений +Light/Dark appearance Світле/темне оформлення +Lighten Освітлення +Lightning Блискавка +Lightning Bolt Блискавка +Line Лінія +Line Color Колір лінії +Line Spacing Міжрядковий інтервал +Line Spacing Options… Параметри міжрядкового інтервалу… +Line delta Різниця рядків +Line spacing (lines): Міжрядковий інтервал (рядки): +Line with Markers Лінійна з маркерами +Linear Лінійний +Lines Лінії +Lines +/− Рядки +/− +Lines added Додані рядки +Lines deleted Видалені рядки +Link Посилання +Link… Посилання… +List Commands Список команд +Lock Заблокувати +Long Dash Довгий штрих +Long Dash Dot Довгий штрих і крапка +Loop continuously until 'Esc' Повторювати безперервно до натискання «Esc» +Loop until Stopped Повторювати до зупинки +Loops Петлі +Loose Розріджений +Low Низька +MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors MIT OR Apache-2.0 · © 2026 Команда ArtCraft та учасники DeckCraft +Margins (pt) Поля (пт) +Mark as decorative Позначити як декоративний +Mark selected text as: Позначити вибраний текст як: +Math Математика +Matrix Матриця +Maximize content (else ensure fit) Збільшити вміст (інакше вмістити) +Media Медіа +Media Info Відомості про медіа +Medium Середній +Merge Cells Об’єднати клітинки +Merge Shapes Об’єднати фігури +Merge\nCells Об’єднати клітинки +Merge\nShapes Об’єднати фігури +Merged PRs Об’єднані запити на злиття +Middle Посередині +Minus Sign Знак мінуса +Model Модель +Models Моделі +Moon Місяць +More Colors… # Інші кольори… # +Morph Трансформація +Motion Рух +Motion Paths Шляхи переміщення +Move Переміщення +Move Down Перемістити вниз +Move Insertion Point Перемістити курсор введення +Move Section Перемістити розділ +Move Section Down Перемістити розділ униз +Move Section Up Перемістити розділ угору +Move Slide Перемістити слайд +Move Up Перемістити вгору +Multiplication Sign Знак множення +Multiply Множення +Mute Вимкнути звук +Name Ім’я +Name (A–Z) Ім'я (А–Я) +Name: Назва: +New Comment Новий коментар +New Presentation Нова презентація +New Slide Новий слайд +New Slide (⇧⌘N) Новий слайд (⇧⌘N) +New presentation Нова презентація +New show name: Назва нового показу: +New\nComment Новий коментар +New\nSlide Новий слайд +Next Далі +Next Slide Наступний слайд +Next Window Наступне вікно +Next ▶ Далі ▶ +No Color Без кольору +No Fill Без заливки +No Glow Без світіння +No Outline Без обведення +No Reflection Без відбиття +No Shadow Без тіні +No Soft Edges Без розмиття країв +No accessibility issues found. People with disabilities should not have difficulty reading this document. Проблем доступності не виявлено. Люди з інвалідністю не повинні мати труднощів із читанням цього документа. +No comments on this slide. На цьому слайді немає коментарів. +No contributor data was built into this copy. У цю копію не вбудовано дані про учасників. +No fill Без заливки +No line Без лінії +No model credits were built into this copy. У цю копію не вбудовано дані про використані моделі. +No notes. Немає приміток. +No sound: {err} Немає звуку: {err} +None Немає +Normal Звичайний +Not Equal Не дорівнює +Not allowed Не дозволено +Notes Примітки +Notes Master Зразок приміток +Notes Master (coming soon) Зразок приміток (незабаром) +Notes Page Сторінка приміток +Notes Pages Сторінки приміток +Notes\nMaster Зразок приміток +Notes\nPage Сторінка приміток +Nudge Зсунути +Number of columns: Кількість стовпців: +Number of rows: Кількість рядків: +Numbering Нумерація +Object Color Колір об’єкта +Objects Об’єкти +Octagon Восьмикутник +On Click Після натискання +On Click of {name} Після натискання на {name} +On Click of… (none) Після натискання на… (немає) +On Mouse Click Після натискання мишею +On-screen Show (16:10) Екранний показ (16:10) +Open Data Відкрити дані +Open Dialog Відкрити діалог +Open Recovered Presentations Відкрити відновлені презентації +Open the sample deck Відкрити зразок презентації +Open… Відкрити… +Orange Помаранчевий +Orbit Орбіта +Order Objects Порядок об’єктів +Origami Оригамі +Out Назовні +Out Bounce Назовні з відскоком +Outer Зовнішня +Outline Структура +Outline (.txt) Структура (.txt) +Outline View Режим структури +Outline\nView Режим структури +Oval Овал +Oval ends Овальні кінці +Overhead Діапроєктор +PDF document (.pdf) Документ PDF (.pdf) +PNG images (all slides) Зображення PNG (усі слайди) +PNG images (current slide) Зображення PNG (поточний слайд) +PRs Запити на злиття +Page Curl Завертання сторінки +Page Setup… Параметри сторінки… +PageDown PageDown +PageUp PageUp +Pan Панорамування +Pane Панель +Paragraph Абзац +Paragraph… Абзац… +Parallelogram Паралелограм +Pareto Парето +Partial Circle Частина кола +Particles Частинки +Paste Вставити +Paste (⌘V) Вставити (⌘V) +Paste Formatting Вставити форматування +Paste and Match Formatting Вставити з відповідним форматуванням +Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets. Вставте або введіть структуру. Рядки без відступу стають заголовками слайдів; рядки з відступом табуляції — пунктами списку. +Pasted image.png Вставлене зображення.png +Path Контур +Pattern fill Візерункова заливка +Pause Пауза +Peek In Визирання +Peel Off Відшарування +Pencil Олівець +Pentagon П’ятикутник +Perspective Перспектива +Pick a graphic; type one item per line. Виберіть графіку; введіть по одному елементу в рядку. +Pick a look for your slides. Ideas are generated on your device. Виберіть оформлення слайдів. Ідеї створюються на вашому пристрої. +Picture Зображення +Picture Format Формат зображення +Picture Styles Стилі зображення +Picture from File… Зображення з файлу… +Picture or texture fill Заливка зображенням або текстурою +Picture\nStyles Стилі зображення +Pictures Зображення +Pie Секторна +Pin Прикріпити +Placeholder colour Колір заповнювача +Plan\nBuild\nLaunch Планування\nСтворення\nЗапуск +Plaque Табличка +Play Відтворити +Play Across Slides Відтворювати на кількох слайдах +Play Full Screen Відтворювати на весь екран +Play Narrations Відтворювати звуковий супровід +Play from Current Slide Відтворити з поточного слайда +Play from Start Відтворити від початку +Play from Start (F5) Відтворити від початку (F5) +Play from\nCurrent Slide Відтворити з поточного слайда +Play from\nStart Відтворити від початку +Play/Pause Відтворити/призупинити +Playback Відтворення +Plus Плюс +Plus In Плюс усередину +Plus Out Плюс назовні +Plus Sign Знак плюса +Portrait (9:16) Портретний (9:16) +Português Português +Position Положення +Position Objects Позиція об’єктів +Position and Size Позиція та розмір +Post Надіслати +Poster Frame Кадр афіші +PowerPoint Presentation (.pptx) Презентація PowerPoint (.pptx) +Preferences Налаштування +Preferences… Налаштування… +Presentation Презентація +Presentations Презентації +Presentations and slide shows, rebuilt from scratch in pure Rust. Презентації та покази слайдів, створені з нуля мовою Rust. +Presented by a speaker (full screen) Доповідачем (повноекранний режим) +Presenter View Режим доповідача +Presenter View — DeckCraft Режим доповідача — DeckCraft +Presenter\nView Режим доповідача +Preset gradients Готові градієнти +Prestige Престиж +Preview Попередній перегляд +Previous Slide Попередній слайд +Print layout: Макет друку: +Properties Властивості +Pulse Пульсація +Punctuation Пунктуація +Purple Фіолетовий +Push Зсув +Pyramid Піраміда +Quick Styles Експрес-стилі +Quick\nStyles Експрес-стилі +Radar Пелюсткова +Radial Радіальний +Random Випадковий +Random Bars Випадкові смуги +Reading View Режим читання +Reading\nView Режим читання +Real name Справжнє ім'я +Recent Нещодавні +Record Запис +Recording narration needs audio capture (coming soon) Для запису звукового супроводу потрібне захоплення аудіо (незабаром) +Recovered Presentations Відновлені презентації +Recovered {} from the last session Відновлено {} з попереднього сеансу +Rectangle Прямокутник +Rectangle: Diagonal Corners Rounded Прямокутник: заокруглені діагональні кути +Rectangle: Diagonal Corners Snipped Прямокутник: зрізані діагональні кути +Rectangle: Rounded Corners Прямокутник: заокруглені кути +Rectangle: Single Corner Rounded Прямокутник: один заокруглений кут +Rectangle: Single Corner Snipped Прямокутник: один зрізаний кут +Rectangle: Top Corners One Rounded and One Snipped Прямокутник: один верхній кут заокруглений, інший зрізаний +Rectangle: Top Corners Rounded Прямокутник: заокруглені верхні кути +Rectangle: Top Corners Snipped Прямокутник: зрізані верхні кути +Rectangles Прямокутники +Rectangular Прямокутний +Red Червоний +Red Pen Червоне перо +Redo Повторити +Redo (⌘Y) Повторити (⌘Y) +Reflected Rounded Rectangle Заокруглений прямокутник із відбиттям +Reflection Відбиття +Regroup Перегрупувати +Regular Pentagon Правильний п’ятикутник +Rehearse Timings Репетиція часу показу +Rehearse\nTimings Репетиція часу показу +Remove Вилучити +Remove All Animations Видалити всі анімації +Remove All Sections Видалити всі розділи +Remove Animation Видалити анімацію +Remove Background Видалити тло +Remove Background (coming soon) Видалення тла (незабаром) +Remove Section Видалити розділ +Remove Section & Slides Видалити розділ і слайди +Remove stop Видалити опорну точку +Remove\nBackground Видалити тло +Rename Перейменувати +Rename Layout Перейменувати макет +Rename Section Перейменувати розділ +Rename Section… Перейменувати розділ… +Rename Slide Перейменувати слайд +Rename… Перейменувати… +Render Slide Візуалізувати слайд +Reopen Відкрити знову +Reorder Animation Змінити порядок анімації +Reorder Objects Змінити порядок об’єктів +Replace straight quotes with smart quotes Замінювати прямі лапки типографськими +Reply Відповісти +Reply… Відповісти… +Reset Скинути +Reset Background Скинути тло +Reset Crop Скинути обтинання +Reset Effects Скинути ефекти +Reset Picture Скинути зображення +Reset Slide Скинути слайд +Reset Slide Background Скинути тло слайда +Resize shape to fit text Змінити розмір фігури за текстом +Resolve Позначити як вирішений +Resolve Thread Завершити обговорення +Return Повернутися +Reveal Відкриття +Reverse the order Зворотний порядок +Revert Повернути попередню версію +Review Рецензування +Rewind after Playing Перемотати після відтворення +Ribbon Tab Вкладка стрічки +Ribbon: Tilted Down Стрічка: нахил униз +Ribbon: Tilted Up Стрічка: нахил угору +Right Праворуч +Right Brace Права фігурна дужка +Right Bracket Права квадратна дужка +Right Triangle Прямокутний трикутник +Right-to-Left Text Direction Напрямок тексту справа наліво +Ring Кільце +Ripple Брижі +Rise Up Підняття +Rotate Повернути +Rotate Left 90° Повернути ліворуч на 90° +Rotate Right 90° Повернути праворуч на 90° +Rotate all text 270° Повернути весь текст на 270° +Rotate all text 90° Повернути весь текст на 90° +Rotate with shape Повертати разом із фігурою +Rotation Обертання +Round Dot Кругла крапка +Rounded Diagonal Corner Заокруглений діагональний кут +Ruler Лінійка +Saturation Насиченість +Saturation {}% Насиченість {}% +Save Зберегти +Save (⌘S) Зберегти (⌘S) +Save As… Зберегти як… +Save AutoRecover Information Зберегти дані автоматичного відновлення +Save as Template… Зберегти як шаблон… +Save to Data Зберегти в дані +Saved Збережено +Saved {path} Збережено: {path} +Scatter Точкова +ScreenTip: Підказка: +Screenshot Знімок екрана +Screenshot (not available yet) Знімок екрана (ще недоступно) +Scroll: Horizontal Сувій: горизонтальний +Scroll: Vertical Сувій: вертикальний +Search commands (⇧⌘P) Пошук команд (⇧⌘P) +Search commands… Пошук команд… +Section Розділ +Seek Перейти до позиції +Select Виділення +Select All Виділити все +Select All Text Вибрати весь текст +Select Cells Вибрати клітинки +Select Objects Вибрати об’єкти +Select Paragraph Вибрати абзац +Select Slides Вибрати слайди +Select Text Вибрати текст +Select Word Вибрати слово +Select a chart first. Спочатку виберіть діаграму. +Select a shape to format it. Виберіть фігуру для форматування. +Select an object on the slide and add an animation from the Animations tab. Виберіть об’єкт на слайді та додайте анімацію на вкладці «Анімації». +Selection Виділення +Selection Pane Панель вибору +Selection Pane… Панель вибору… +Selection\nPane Панель вибору +Send Backward Перемістити назад +Send to Back На задній план +Send\nBackward Перемістити назад +Sentence case. Як у реченні. +Set Text Задати текст +Set Up Show… Налаштувати показ… +Set Up Slide Show Налаштувати показ слайдів +Set Up Slide Show… Налаштувати показ слайдів… +Set Up\nSlide Show Налаштувати показ слайдів +Set as Default Shape Установити як стандартну фігуру +Shading Затінення +Shadow Тінь +Shape Фігура +Shape Effects Ефекти фігури +Shape Fill Заливка фігури +Shape Format Формат фігури +Shape Gallery Галерея фігур +Shape Outline Обведення фігури +Shape Styles Стилі фігур +Shape {} Фігура {} +Shape\nStyles Стилі фігур +Shapes Фігури +Shift+ Shift+ +Shift+F9 Shift+F9 +Show Показати +Show All Показати всі +Show Comments Показати коментарі +Show Media Controls Показати елементи керування медіа +Show Slide Показати слайд +Show smart guides Показувати розумні напрямні +Show type Тип показу +Show without animation Показ без анімації +Show without narration Показ без звукового супроводу +Show/Hide Показати/приховати +Show\nComments Показати коментарі +Shred Подрібнення +Shrink & Turn Зменшення й обертання +Shrink text on overflow Зменшувати текст у разі переповнення +Simple Frame, White Проста біла рамка +Size Розмір +Size & Properties Розмір і властивості +Slide Master Зразок слайдів +Slide Number Номер слайда +Slide Show Показ слайдів +Slide Size Розмір слайда +Slide Sorter Сортувальник слайдів +Slide number Номер слайда +Slide timings recorded. Час показу слайдів записано. +Slide {:02}:{:04.1} Total {:02}:{:02} Слайд {:02}:{:04.1} Загалом {:02}:{:02} +Slide {} of {} Слайд {} з {} +Slide {}: {}{} Слайд {}: {}{} +Slide {}: “{w}” Слайд {}: «{w}» +Slide\nMaster Зразок слайдів +Slide\nNumber Номер слайда +Slide\nSize Розмір слайда +Slide\nSorter Сортувальник слайдів +Slides (e.g. 1,3,5): Слайди (наприклад, 1,3,5): +Slides from Outline Слайди зі структури +Slides from Outline… Слайди зі структури… +Slides sized for: Розмір слайдів для: +SmartArt SmartArt +Smile Усмішка +Smiley Face Усміхнене обличчя +Smoothly Плавно +Smoothly Left Плавно ліворуч +Smoothly Right Плавно праворуч +Snap objects to grid Прив’язувати об’єкти до сітки +Soft Edge Rectangle Прямокутник із розмитими краями +Soft Edges Розмиті краї +Solid Суцільний +Solid fill Суцільна заливка +Solid line Суцільна лінія +Sort Сортування +Source on GitHub Вихідний код на GitHub +Space Space +Spacing after (pt): Інтервал після (пт): +Spacing before (pt): Інтервал перед (пт): +Special — hanging (in): Спеціальний — виступ (дюйми): +Speech Мовлення +Speech Bubble: Oval Овальна мовна бульбашка +Speech Bubble: Rectangle Прямокутна мовна бульбашка +Speech Bubble: Rectangle with Corners Rounded Мовна бульбашка: прямокутник із заокругленими кутами +Spelling Орфографія +Spin Обертання +Spinner Кругове обертання +Split Розділення +Split Cells Розділити клітинки +Split\nCells Розділити клітинки +Square Квадрат +Square (1:1) Квадратний (1:1) +Square Dot Квадратна крапка +Stacked Накопичувальна +Stacked Area З областями з накопиченням +Stacked Bar Лінійчата з накопиченням +Stacked Column Стовпчаста з накопиченням +Stacked Line Лінійна з накопиченням +Standard (4:3) Стандартний (4:3) +Standard Colors Стандартні кольори +Star Зірка +Star: 10 Points Зірка: 10 променів +Star: 12 Points Зірка: 12 променів +Star: 16 Points Зірка: 16 променів +Star: 24 Points Зірка: 24 промені +Star: 32 Points Зірка: 32 промені +Star: 4 Points Зірка: 4 промені +Star: 5 Points Зірка: 5 променів +Star: 6 Points Зірка: 6 променів +Star: 7 Points Зірка: 7 променів +Star: 8 Points Зірка: 8 променів +Stars and Banners Зірки та стрічки +Start After Previous Почати після попереднього +Start Automatically Почати автоматично +Start On Click Почати після натискання +Start With Previous Почати разом із попереднім +Start a conversation Почніть розмову +Start time (s): Час початку (с): +Start: Початок: +Stealth end Вістря +Stock Біржова +Stop Зупинити +Stop Editing Text Завершити редагування тексту +Strikethrough Закреслення +Strips Смуги +Style {i} Стиль {i} +Subscript Підрядковий +Subtle Стримані +Sun Сонце +Sunburst Сонячні промені +Superscript Надрядковий +Surface Поверхнева +Switch Перемикання +Swivel Обертання +Symbol Символ +System language Мова системи +Tab Табуляція +Table Таблиця +Table Design Оформлення таблиці +Table Style Options Параметри стилю таблиці +Table Styles Стилі таблиці +Table… Таблиця… +Task Pane Панель завдань +Teardrop Крапля +Teeter Хитання +Text Текст +Text Box Текстове поле +Text Box Margins Поля текстового блока +Text Direction Напрямок тексту +Text Effects Текстові ефекти +Text Fill Заливка тексту +Text Highlight Color Колір виділення тексту +Text Options Параметри тексту +Text Outline Обведення тексту +Text Shadow Тінь тексту +Text/Background - Dark 1 Текст/тло — темний 1 +Text/Background - Dark 2 Текст/тло — темний 2 +Text/Background - Light 1 Текст/тло — світлий 1 +Text/Background - Light 2 Текст/тло — світлий 2 +Text\nBox Текстове поле +The spelling check is complete. No issues found. Перевірку правопису завершено. Помилок не виявлено. +Theme Colors Кольори теми +Theme Gallery Галерея тем +Themes Теми +Thesaurus Тезаурус +Thesaurus (coming soon) Тезаурус (незабаром) +This Document Цей документ +Thought Думка +Thought Bubble: Cloud Бульбашка думки: хмара +Through Black Через чорний +Tight Ущільнений +Timing Час показу +Top Верх +Total Row Рядок підсумку +Transition Перехід +Transition Gallery Галерея переходів +Transitions Переходи +Translate Перекласти +Translation needs a language model (not bundled) Для перекладу потрібна мовна модель (не входить до комплекту) +Transparency Прозорість +Trapezoid Трапеція +Treemap Деревоподібна +Triangle Трикутник +Trigger Умова запуску +Trim Обрізати +Trim Media Обрізати медіа +Turns Повороти +Type Тип +Type Text Ввести текст +Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). Введіть рівняння в лінійному вигляді (наприклад, a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). +Uncover Відкривання +Underline Підкреслення +Undo Скасувати +Undo (⌘Z) Скасувати (⌘Z) +Ungroup Розгрупувати +Up Угору +Use Timings Використовувати час показу +Use timings, if present Використовувати час показу, якщо задано +Username Ім'я користувача +Version Версія +Version {} Версія {} +Vert In Вертикально всередину +Vert Out Вертикально назовні +Vertical Вертикально +Vertical alignment Вертикальне вирівнювання +Very Loose Дуже розріджений +Very Tight Дуже ущільнений +Video Відео +Video from File… Відео з файлу… +View Перегляд +Volume Гучність +Vortex Вихор +Waterfall Водоспадна +Wave Хвиля +Web Page or File Вебсторінка або файл +Wedge Клин +Weight Товщина +Wheel Колесо +Widescreen (13.333x7.5 in) Широкоекранний (13,333×7,5 дюймів) +Widescreen (16:9) Широкоекранний (16:9) +Width Ширина +Width (in): Ширина (дюйми): +Width: Ширина: +Width: Ширина: +Wind Вітер +Window Вікно +Wipe Витирання +With Previous Разом із попереднім +WordArt WordArt +WordArt style {} Стиль WordArt {} +Words Слова +Wrap Text in Shape Переносити текст у фігурі +Wrap text in shape Переносити текст у фігурі +Yellow Жовтий +Your changes will be lost if you don't save them. Якщо не зберегти, зміни будуть втрачені. +Your name (comments): Ваше ім’я (коментарі): +Zoom Масштаб +Zoom In Збільшити +Zoom Out Зменшити +Zoom… Масштаб… +a) b) c) a) b) c) +a. b. c. a. b. c. +dropped file перетягнутий файл +i. ii. iii. i. ii. iii. +no presentation open Немає відкритої презентації +no slides Немає слайдів +no text insertion point Немає позиції введення тексту +nothing selected Нічого не вибрано +nothing to redo Немає дій для повторення +nothing to undo Немає дій для скасування +select text or a shape Виберіть текст або фігуру +select two or more objects Виберіть два або більше об’єктів +size (pt) розмір (пт) +tOGGLE cASE tOGGLE cASE +unknown interface language Невідома мова інтерфейсу +{ch} Bullet {ch} Маркер +{f} (Body) {f} (Основний текст) +{f} (Headings) {f} (Заголовки) +{name}: DeckCraft can't open this kind of file {name}: DeckCraft не може відкрити файл цього типу +{n} Column{} {n} Стовпець{} +{r} pt {r} пт +{r} pt glow світіння {r} пт +{w} pt {w} пт +{} ({} slides) {} ({} слайдів) +{} Slide Master {} Зразок слайдів +{} contributors · {} commits Учасники: {} · Коміти: {} +{} presentations {} презентацій +{}x{} Table Таблиця {}×{} +“{}” is not available yet. «{}» ще недоступно. +−Bin −Бін +▶ Play All ▶ Відтворити все +◀ Previous ◀ Назад +✏ on ✏ увімкнено diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bef64a4f2..62e18a217 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ pub mod control; pub mod credits; pub mod dialogs; pub mod fillui; +pub mod i18n; pub mod icons; pub mod media; pub mod menus; @@ -43,6 +44,8 @@ pub type Inbox = std::sync::Arc)>>>; /// Platform services injected by the host (desktop or web). #[derive(Default)] pub struct Services { + /// Host locale (OS on desktop, navigator.language on the web). + pub system_locale: Option, /// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path. pub pick_open: Option, /// Save dialog with a suggested name → path. @@ -75,6 +78,7 @@ pub enum ViewMode { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + pub interface_language: i18n::Language, pub brightness: theme::Brightness, pub tab: String, pub ribbon_collapsed: bool, @@ -103,6 +107,7 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + interface_language: i18n::Language::System, brightness: theme::Brightness::Light, tab: "Home".into(), ribbon_collapsed: false, @@ -154,6 +159,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.about", "About DeckCraft", None, "{}"), ("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"), ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), + ("app.language", "Interface Language", None, "{language: system|en|uk}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), ("app.insertAudioDialog", "Audio from File…", None, "{}"), @@ -253,12 +259,15 @@ impl SlideApp { } pub fn set_status(&mut self, msg: impl Into) { - self.status = Some((msg.into(), now_ms())); + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); + let msg = msg.into(); + self.status = Some((i18n::tr(&msg).to_string(), now_ms())); } /// Run a command by id (UI commands here, everything else in the engine). Errors show in the /// status bar and are returned. pub fn run(&mut self, id: &str, params: Value) -> Result { + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { self.run_ui(id, ¶ms) } else { @@ -345,6 +354,16 @@ impl SlideApp { } "app.about" => self.dialog = Some(dialogs::Dialog::new("about")), "app.palette" => self.palette = Some((String::new(), 0)), + "app.language" => { + let language = p.get("language").and_then(Value::as_str).unwrap_or("system"); + self.ui.interface_language = match language { + "system" => i18n::Language::System, + "en" => i18n::Language::En, + "uk" => i18n::Language::Uk, + _ => return Err("unknown interface language".into()), + }; + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); + } "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), @@ -418,7 +437,7 @@ impl SlideApp { if let Some(path) = pick(&suggested) { match self.session.execute("file.saveAs", &json!({"path": path})) { Ok(_) => { - self.set_status(format!("Saved {path}")); + self.set_status(crate::i18n::format("Saved {path}", std::slice::from_ref(&path))); self.ui.recent.retain(|p| *p != path); self.ui.recent.insert(0, path); } @@ -516,7 +535,7 @@ impl SlideApp { } else if lower.ends_with(".txt") || lower.ends_with(".md") { "slide.fromOutline" } else { - self.set_status(format!("{name}: DeckCraft can't open this kind of file")); + self.set_status(crate::i18n::format("{name}: DeckCraft can't open this kind of file", &[(name).to_string()])); return; }; let p = if cmd == "slide.fromOutline" { json!({"text": String::from_utf8_lossy(bytes)}) } else { json!({"name": name, "data": data}) }; @@ -530,6 +549,7 @@ impl SlideApp { /// Per-frame logic before layout: styles, control requests, screenshots, shortcuts. pub fn logic(&mut self, ctx: &egui::Context) { + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); let scale = self.ui.ui_scale.clamp(0.5, 3.0); if (ctx.zoom_factor() - scale).abs() > 1e-3 { ctx.set_zoom_factor(scale); @@ -574,7 +594,7 @@ impl SlideApp { if self.session.documents().iter().any(|d| d.is_dirty()) && let Err(e) = self.session.execute("file.recovery.save", &json!({})) { - self.set_status(format!("Couldn't save AutoRecover information: {e}")); + self.set_status(crate::i18n::format("Couldn't save AutoRecover information: {e}", &[(e).to_string()])); } } // On the web, dropped files can only be read asynchronously: the host reads them and @@ -682,6 +702,7 @@ impl SlideApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); let ctx = ui.ctx().clone(); if !self.fonts_ready { ctx.request_repaint(); diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index f887c2b21..a5a939770 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -380,7 +380,7 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: // Time label on the right, timeline between. let label = clock(pos); let label_w = 64.0; - painter.text(pos2(rect.max.x - 6.0, rect.center().y), Align2::RIGHT_CENTER, label, egui::FontId::monospace(11.0), white); + painter.text(pos2(rect.max.x - 6.0, rect.center().y), Align2::RIGHT_CENTER, &label, egui::FontId::monospace(11.0), white); let track = Rect::from_min_max(pos2(x + 6.0, rect.center().y - 3.0), pos2(rect.max.x - label_w - 6.0, rect.center().y + 3.0)); if track.width() > 10.0 { painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(90)); @@ -414,7 +414,7 @@ pub fn control_bar(ui: &mut Ui, rect: Rect, status: Option<&VoiceStatus>, clip: painter.text( pos2(rect.min.x, rect.max.y + 3.0), Align2::LEFT_TOP, - format!("No sound: {err}"), + crate::i18n::format("No sound: {err}", std::slice::from_ref(err)), egui::FontId::proportional(10.0), Color32::from_rgb(0xC0, 0x40, 0x40), ); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9349dc360..c46eb0e15 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -63,7 +63,12 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 90.0)) .fixed_size(egui::vec2(520.0, 380.0)) .show(ctx, |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0))); + let r = ui.add( + egui::TextEdit::singleline(&mut query) + .hint_text(crate::i18n::tr("Search commands…")) + .desired_width(f32::INFINITY) + .font(theme::font(15.0)), + ); r.request_focus(); let q = query.to_lowercase(); let mut items: Vec<(String, String, Option<&'static str>)> = app @@ -71,8 +76,8 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { .commands() .into_iter() .filter(|c| c.enabled) - .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) - .chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2))) + .map(|c| (c.id.to_string(), crate::i18n::tr(c.label).to_string(), c.shortcut)) + .chain(crate::UI_COMMANDS.iter().map(|c| (c.0.to_string(), crate::i18n::tr(c.1).to_string(), c.2))) .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(&q) || id.to_lowercase().contains(&q)) .collect(); items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(&q)); @@ -100,7 +105,7 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (k, (id, label, sc)) in items.iter().enumerate() { let r = ui.horizontal(|ui| { - let resp = ui.selectable_label(k == sel, label); + let resp = ui.selectable_label(k == sel, crate::i18n::tr(label)); if let Some(s) = sc { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.label(egui::RichText::new(crate::ribbon::pretty_shortcut(s)).color(t.text_faint)); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fff1da466..516b7d3f7 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -30,7 +30,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { _ => "Pane", }; ui.horizontal(|ui| { - ui.label(egui::RichText::new(title).font(theme::bold(16.0))); + ui.label(egui::RichText::new(crate::i18n::tr(title)).font(theme::bold(16.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), Sense::click()); if resp.hovered() { @@ -72,7 +72,7 @@ fn color_button( ) { let sc = scheme(app); ui.horizontal(|ui| { - ui.label(label); + ui.label(crate::i18n::tr(label)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let r = widgets::drop_button(ui, "Color", vec2(70.0, 22.0), true); widgets::color_popup(&r).show(|ui| { @@ -89,7 +89,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Some(st) = app.session.active() else { return }; let Some(sh) = st.selected_shapes().first().map(|s| (*s).clone()) else { - ui.label(egui::RichText::new("Select a shape to format it.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Select a shape to format it.")).color(t.text_dim)); return; }; let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, &sh); @@ -115,7 +115,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); match app.ui.format_tab.as_str() { "effects" => { - ui.label(egui::RichText::new("Shadow").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Shadow")).font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { for (l, v) in [ ("None", "none"), @@ -125,54 +125,54 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ("Inner", "inner"), ("Perspective", "perspective"), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "shape.effects", json!({"shadow": v})); } } }); ui.add_space(6.0); - ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Reflection")).font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "shape.effects", json!({"reflection": v})); } } }); ui.add_space(6.0); - ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Glow")).font(theme::bold(13.0))); let mut g = sh.effects.as_ref().and_then(|e| e.glow.as_ref().map(|g| g.radius)).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut g, 0.0..=40.0).text("size (pt)")).drag_stopped() { + if ui.add(egui::Slider::new(&mut g, 0.0..=40.0).text(crate::i18n::tr("size (pt)"))).drag_stopped() { run(app, "shape.effects", json!({"glow": if g > 0.0 { json!(g) } else { Value::Null }})); } - ui.label(egui::RichText::new("Soft Edges").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Soft Edges")).font(theme::bold(13.0))); let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { + if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text(crate::i18n::tr("size (pt)"))).drag_stopped() { run(app, "shape.effects", json!({"softEdges": s})); } - if ui.button("Reset Effects").clicked() { + if ui.button(crate::i18n::tr("Reset Effects")).clicked() { run(app, "shape.effects", json!({"reset": true})); } } "size" => { let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, unit: f64| { ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::tr(label))); if let Some(nv) = widgets::spinner(ui, id, v / unit, 0.1, if unit == 72.0 { "\"" } else { "°" }, 70.0) { run(app, cmd, json!({key: nv * unit})); } }); }; - ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Size")).font(theme::bold(13.0))); fields(ui, "Height", "fh", x.h, "h", "shape.resize", 72.0); fields(ui, "Width", "fw", x.w, "w", "shape.resize", 72.0); fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", 1.0); ui.add_space(6.0); - ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Position")).font(theme::bold(13.0))); fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", 72.0); fields(ui, "Vertical", "fy", x.y, "y", "shape.move", 72.0); ui.add_space(6.0); - ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Alt Text")).font(theme::bold(13.0))); let id = ui.id().with(("alt", sh.id.0)); let mut alt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_else(|| sh.descr.clone())); let r = ui.add(egui::TextEdit::multiline(&mut alt).desired_rows(3).desired_width(f32::INFINITY)); @@ -182,28 +182,28 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, alt)); } "text" => { - ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Text Box")).font(theme::bold(13.0))); ui.horizontal(|ui| { - ui.label("Vertical alignment"); + ui.label(crate::i18n::tr("Vertical alignment")); for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).clicked() { + if ui.small_button(crate::i18n::tr(l)).clicked() { run(app, "format.anchor", json!({"anchor": a})); } } }); ui.horizontal_wrapped(|ui| { for (l, m) in [("Do not Autofit", "none"), ("Shrink text on overflow", "shrink"), ("Resize shape to fit text", "resize")] { - if ui.small_button(l).clicked() { + if ui.small_button(crate::i18n::tr(l)).clicked() { run(app, "format.autofit", json!({"mode": m})); } } }); let wrap = sh.text.as_ref().and_then(|t| t.body.wrap).unwrap_or(true); let mut w = wrap; - if ui.checkbox(&mut w, "Wrap text in shape").changed() { + if ui.checkbox(&mut w, crate::i18n::tr("Wrap text in shape")).changed() { run(app, "format.wrap", json!({"on": w})); } - ui.label("Margins (pt)"); + ui.label(crate::i18n::tr("Margins (pt)")); let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); for (l, k, v) in [ ("Left", "left", b.inset_l.unwrap_or(7.2)), @@ -212,14 +212,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ("Bottom", "bottom", b.inset_b.unwrap_or(3.6)), ] { ui.horizontal(|ui| { - ui.add_sized(vec2(60.0, 20.0), egui::Label::new(l)); + ui.add_sized(vec2(60.0, 20.0), egui::Label::new(crate::i18n::tr(l))); if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), v, 1.0, " pt", 60.0) { run(app, "format.margins", json!({k: nv.max(0.0)})); } }); } ui.horizontal(|ui| { - ui.label("Columns"); + ui.label(crate::i18n::tr("Columns")); for n in 1..=3 { if ui.small_button(n.to_string()).clicked() { run(app, "format.columns", json!({"count": n, "spacing": 18})); @@ -230,29 +230,29 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { "picture" => { let adj = if let ShapeKind::Picture { fill } = &sh.kind { fill.adjust.clone() } else { Default::default() }; let mut b = adj.brightness as f32; - if ui.add(egui::Slider::new(&mut b, -1.0..=1.0).text("Brightness")).drag_stopped() { + if ui.add(egui::Slider::new(&mut b, -1.0..=1.0).text(crate::i18n::tr("Brightness"))).drag_stopped() { run(app, "picture.adjust", json!({"brightness": b})); } let mut c = adj.contrast as f32; - if ui.add(egui::Slider::new(&mut c, -1.0..=1.0).text("Contrast")).drag_stopped() { + if ui.add(egui::Slider::new(&mut c, -1.0..=1.0).text(crate::i18n::tr("Contrast"))).drag_stopped() { run(app, "picture.adjust", json!({"contrast": c})); } let mut s = adj.saturation.unwrap_or(1.0) as f32; - if ui.add(egui::Slider::new(&mut s, 0.0..=4.0).text("Saturation")).drag_stopped() { + if ui.add(egui::Slider::new(&mut s, 0.0..=4.0).text(crate::i18n::tr("Saturation"))).drag_stopped() { run(app, "picture.adjust", json!({"saturation": s})); } let alpha = if let ShapeKind::Picture { fill } = &sh.kind { fill.alpha.unwrap_or(1.0) } else { 1.0 }; let mut tr = (1.0 - alpha) as f32; - if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text("Transparency")).drag_stopped() { + if ui.add(egui::Slider::new(&mut tr, 0.0..=1.0).text(crate::i18n::tr("Transparency"))).drag_stopped() { run(app, "picture.adjust", json!({"transparency": tr})); } - if ui.button("Reset Picture").clicked() { + if ui.button(crate::i18n::tr("Reset Picture")).clicked() { run(app, "picture.reset", json!({})); } } _ => { // Fill & Line. - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Fill")).font(theme::bold(13.0))); let kind = match &sh.fill { None => "auto", Some(Fill::None) => "none", @@ -265,7 +265,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { for (l, k) in [("No fill", "none"), ("Solid fill", "solid"), ("Gradient fill", "gradient"), ("Pattern fill", "pattern"), ("Automatic", "auto")] { - if ui.radio(kind == k, l).clicked() { + if ui.radio(kind == k, crate::i18n::tr(l)).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), "solid" => run(app, "shape.fill", json!({"color": "accent1"})), @@ -284,7 +284,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { None => ("shape.fill".into(), json!({"none": true})), }); ui.horizontal(|ui| { - ui.label("Transparency"); + ui.label(crate::i18n::tr("Transparency")); let id = ui.id().with("ftr"); let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(0.0)); let r = ui.add(egui::Slider::new(&mut v, 0.0..=100.0).suffix("%")); @@ -299,21 +299,21 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } let mut rot = g.rotate_with_shape; - if ui.checkbox(&mut rot, "Rotate with shape").changed() { + if ui.checkbox(&mut rot, crate::i18n::tr("Rotate with shape")).changed() { let ng = deckcraft_model::style::Gradient { rotate_with_shape: rot, ..g.clone() }; run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } } ui.add_space(8.0); - ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Line")).font(theme::bold(13.0))); let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); - if ui.radio(none, "No line").clicked() { + if ui.radio(none, crate::i18n::tr("No line")).clicked() { run(app, "shape.line", json!({"none": true})); } - if ui.radio(!none && sh.line.is_some(), "Solid line").clicked() { + if ui.radio(!none && sh.line.is_some(), crate::i18n::tr("Solid line")).clicked() { run(app, "shape.line", json!({"color": "tx1", "width": 1})); } - if ui.radio(sh.line.is_none(), "Automatic").clicked() { + if ui.radio(sh.line.is_none(), crate::i18n::tr("Automatic")).clicked() { run(app, "shape.line", json!({"reset": true})); } color_button(app, ui, "Color", Some("No Outline"), |c| match c { @@ -321,16 +321,16 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { None => ("shape.line".into(), json!({"none": true})), }); ui.horizontal(|ui| { - ui.label("Width"); + ui.label(crate::i18n::tr("Width")); let w = sh.line.as_ref().and_then(|l| l.width).unwrap_or(0.75); if let Some(v) = widgets::spinner(ui, "lw", w, 0.25, " pt", 60.0) { run(app, "shape.line", json!({"width": v.max(0.0)})); } }); ui.horizontal_wrapped(|ui| { - ui.label("Dash"); + ui.label(crate::i18n::tr("Dash")); for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] { - if ui.small_button(l).clicked() { + if ui.small_button(crate::i18n::tr(l)).clicked() { run(app, "shape.line", json!({"dash": d})); } } @@ -355,7 +355,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }; // What "Apply to All" re-applies: the current slide's background as command params. let mut current: Option = None; - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Fill")).font(theme::bold(13.0))); for (l, k) in [ ("Solid fill", "solid"), ("Gradient fill", "gradient"), @@ -363,7 +363,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { ("Pattern fill", "pattern"), ("Follow the layout", "auto"), ] { - if ui.radio(kind == k, l).clicked() && kind != k { + if ui.radio(kind == k, crate::i18n::tr(l)).clicked() && kind != k { match k { "solid" => run(app, "design.background", json!({"color": "bg1"})), "gradient" => { @@ -389,7 +389,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { Some(Fill::Solid { color }) => { current = Some(json!({"color": cref_param(color)})); ui.horizontal(|ui| { - ui.label("Color"); + ui.label(crate::i18n::tr("Color")); let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); widgets::color_popup(&r).show(|ui| { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { @@ -415,7 +415,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); for (label, fg) in [("Foreground", true), ("Background", false)] { ui.horizontal(|ui| { - ui.label(label); + ui.label(crate::i18n::tr(label)); let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); widgets::color_popup(&r).show(|ui| { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { @@ -427,7 +427,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } } Some(Fill::Picture(_)) => { - if ui.button("Insert picture from file…").clicked() + if ui.button(crate::i18n::tr("Insert picture from file…")).clicked() && let Some(pick) = app.services.pick_open.as_mut() && let Some(path) = pick("picture") && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) @@ -440,18 +440,18 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(4.0); let hide = app.session.active().and_then(|d| d.current_slide().map(|s| !s.show_master_shapes)).unwrap_or(false); let mut h = hide; - if ui.checkbox(&mut h, "Hide background graphics").changed() { + if ui.checkbox(&mut h, crate::i18n::tr("Hide background graphics")).changed() { run(app, "design.hideBackgroundGraphics", json!({"hide": h})); } ui.add_space(8.0); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked() + if ui.add_enabled(current.is_some(), egui::Button::new(crate::i18n::tr("Apply to All"))).clicked() && let Some(Value::Object(mut o)) = current.clone() { o.insert("all".into(), json!(true)); run(app, "design.background", Value::Object(o)); } - if ui.button("Reset Background").clicked() { + if ui.button(crate::i18n::tr("Reset Background")).clicked() { run(app, "design.background", json!({"reset": true})); } }); @@ -465,7 +465,7 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { let names: Vec<(u32, String)> = st.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect(); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); ui.horizontal(|ui| { - if ui.button("▶ Play All").clicked() { + if ui.button(crate::i18n::tr("▶ Play All")).clicked() { let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); app.start_show(from, true); if let Some(s) = app.show.as_mut() { @@ -476,7 +476,9 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { }); ui.separator(); if anims.is_empty() { - ui.label(egui::RichText::new("Select an object on the slide and add an animation from the Animations tab.").color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Select an object on the slide and add an animation from the Animations tab.")).color(t.text_dim), + ); return; } let mut click = 0; @@ -484,7 +486,11 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { if a.start == AnimStart::OnClick { click += 1; } - let name = names.iter().find(|(id, _)| *id == a.shape.0).map(|(_, n)| n.clone()).unwrap_or_else(|| format!("Shape {}", a.shape)); + let name = names + .iter() + .find(|(id, _)| *id == a.shape.0) + .map(|(_, n)| n.clone()) + .unwrap_or_else(|| crate::i18n::format("Shape {}", &[(a.shape).to_string()])); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); let sel = selected.contains(&a.shape.0); if sel || resp.hovered() { @@ -525,21 +531,21 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { } resp.context_menu(|ui| { for (l, v) in [("Start On Click", "onClick"), ("Start With Previous", "withPrevious"), ("Start After Previous", "afterPrevious")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "animation.timing", json!({"index": i, "start": v})); ui.close(); } } ui.separator(); - if ui.button("Move Up").clicked() { + if ui.button(crate::i18n::tr("Move Up")).clicked() { run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); ui.close(); } - if ui.button("Move Down").clicked() { + if ui.button(crate::i18n::tr("Move Down")).clicked() { run(app, "animation.move", json!({"index": i, "to": i + 1})); ui.close(); } - if ui.button("Remove").clicked() { + if ui.button(crate::i18n::tr("Remove")).clicked() { run(app, "animation.remove", json!({"index": i})); ui.close(); } @@ -556,11 +562,11 @@ fn selection(app: &mut SlideApp, ui: &mut Ui) { st.shapes().iter().rev().map(|s| (s.id.0, s.name.clone(), s.hidden, s.kind_name())).collect(); let selected: Vec = st.selection.shapes.iter().map(|i| i.0).collect(); ui.horizontal(|ui| { - if ui.button("Show All").clicked() { + if ui.button(crate::i18n::tr("Show All")).clicked() { let ids: Vec = shapes.iter().map(|s| s.0).collect(); run(app, "shape.visible", json!({"ids": ids, "visible": true})); } - if ui.button("Hide All").clicked() { + if ui.button(crate::i18n::tr("Hide All")).clicked() { let ids: Vec = shapes.iter().map(|s| s.0).collect(); run(app, "shape.visible", json!({"ids": ids, "visible": false})); } @@ -580,7 +586,7 @@ fn selection(app: &mut SlideApp, ui: &mut Ui) { theme::font(12.5), if hidden { t.text_faint } else { t.text }, ); - ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, kind, theme::font(10.5), t.text_faint); + ui.painter().text(pos2(r.max.x - 34.0, r.center().y), Align2::RIGHT_CENTER, crate::i18n::tr(kind), theme::font(10.5), t.text_faint); let eye = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(16.0, 16.0)); icons::paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, t.text_dim, false); let eye_resp = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); @@ -596,15 +602,15 @@ fn selection(app: &mut SlideApp, ui: &mut Ui) { app.dialog = Some(d); } resp.context_menu(|ui| { - if ui.button("Bring Forward").clicked() { + if ui.button(crate::i18n::tr("Bring Forward")).clicked() { run(app, "arrange.bringForward", json!({"ids": [id]})); ui.close(); } - if ui.button("Send Backward").clicked() { + if ui.button(crate::i18n::tr("Send Backward")).clicked() { run(app, "arrange.sendBackward", json!({"ids": [id]})); ui.close(); } - if ui.button("Rename…").clicked() { + if ui.button(crate::i18n::tr("Rename…")).clicked() { let mut d = crate::dialogs::Dialog::new("renameShape"); d.params = json!({"id": id, "name": name}); app.dialog = Some(d); @@ -617,14 +623,14 @@ fn selection(app: &mut SlideApp, ui: &mut Ui) { fn comments(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - if ui.button("+ New Comment").clicked() { + if ui.button(crate::i18n::tr("+ New Comment")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("comment")); } ui.separator(); let Some(st) = app.session.active() else { return }; let list = st.current_slide().map(|s| s.comments.clone()).unwrap_or_default(); if list.is_empty() { - ui.label(egui::RichText::new("No comments on this slide.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("No comments on this slide.")).color(t.text_dim)); } for (i, c) in list.iter().enumerate() { egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(8)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { @@ -645,7 +651,7 @@ fn comments(app: &mut SlideApp, ui: &mut Ui) { ui.horizontal(|ui| { let id = ui.id().with(("reply", i)); let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text(crate::i18n::tr("Reply…")).desired_width(ui.available_width() - 70.0)); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { run(app, "comment.reply", json!({"index": i, "text": txt})); txt.clear(); @@ -666,7 +672,7 @@ fn comments(app: &mut SlideApp, ui: &mut Ui) { /// Design Ideas: offline suggestions — the current slide in each theme and layout variations. fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Pick a look for your slides. Ideas are generated on your device.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Pick a look for your slides. Ideas are generated on your device.")).color(t.text_dim)); ui.add_space(6.0); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c7c251efe..e737f7be2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -62,8 +62,11 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { if icon_toggle(&mut child, Icon::PlayFromStart, "Play from Start (F5)", has_doc, false).clicked() { app.start_show(0, false); } - let title = - app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); + let title = app + .session + .active() + .map(|d| format!("{}{}", d.title(), if dirty { crate::i18n::tr(" — Edited") } else { "" })) + .unwrap_or_else(|| "DeckCraft".into()); ui.painter().text(rect.center(), Align2::CENTER_CENTER, title, theme::font(13.0), t.text); // Right side. let mut right = ui.new_child( @@ -76,7 +79,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { app.palette = Some((String::new(), 0)); } let share = right.add( - egui::Button::new(egui::RichText::new(" Present ").color(t.accent_text).font(theme::bold(13.0))) + egui::Button::new(egui::RichText::new(crate::i18n::tr(" Present ")).color(t.accent_text).font(theme::bold(13.0))) .fill(t.accent) .corner_radius(CornerRadius::same(13)) .min_size(vec2(84.0, 26.0)), @@ -148,8 +151,13 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let y = rect.min.y + 2.0; let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect(); for (name, contextual) in all { + let label = match name { + "Home" => crate::i18n::tr_context(name, "@ribbon:Home"), + "Insert" => crate::i18n::tr_context(name, "@ribbon:Insert"), + _ => crate::i18n::tr(name), + }; let font = theme::font(13.0); - let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0; + let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 18.0; let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); let resp = ui.interact(r, ui.id().with(("tab", name)), Sense::click()); let active = app.ui.tab == name; @@ -158,7 +166,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } let col = if contextual { t.contextual } else { t.text }; let f = if active { theme::bold(13.0) } else { font }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col); + ui.painter().text(r.center(), Align2::CENTER_CENTER, label, f, col); if active { let uw = (w - 20.0).max(16.0); ui.painter().rect_filled(Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(uw, 3.0)), CornerRadius::same(1), t.accent); @@ -234,6 +242,7 @@ fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on } fn tip(id: &str, label: &str) -> String { + let label = crate::i18n::tr(label); let sc = deckcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.2)); match sc { Some(s) => format!("{label} ({})", pretty_shortcut(s)), @@ -266,18 +275,18 @@ pub fn fmt_state(app: &mut SlideApp) -> Value { fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false); - if main.on_hover_text("Paste (⌘V)").clicked() { + if main.on_hover_text(crate::i18n::tr("Paste (⌘V)")).clicked() { run(app, "edit.paste", json!({})); } if let Some(a) = arrow { egui::Popup::menu(&a).show(|ui| { - if ui.button("Paste").clicked() { + if ui.button(crate::i18n::tr("Paste")).clicked() { run(app, "edit.paste", json!({})); } - if ui.button("Paste and Match Formatting").clicked() { + if ui.button(crate::i18n::tr("Paste and Match Formatting")).clicked() { run(app, "edit.pasteText", json!({})); } - if ui.button("Paste Formatting").clicked() { + if ui.button(crate::i18n::tr("Paste Formatting")).clicked() { run(app, "format.painterApply", json!({})); } }); @@ -287,7 +296,8 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); let on = app.session.painter.is_some(); let en = enabled(app, "format.painter"); - let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); + let r = + small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text(crate::i18n::tr("Format Painter (double-click to keep it on)")); if r.double_clicked() { run(app, "format.painter", json!({"sticky": true})); } else if r.clicked() { @@ -352,17 +362,17 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { fn slides_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false); - if main.on_hover_text("New Slide (⇧⌘N)").clicked() { + if main.on_hover_text(crate::i18n::tr("New Slide (⇧⌘N)")).clicked() { run(app, "slide.new", json!({})); } if let Some(a) = arrow { egui::Popup::menu(&a).width(420.0).show(|ui| { layout_menu(app, ui, "slide.new"); ui.separator(); - if ui.button("Duplicate Selected Slides").clicked() { + if ui.button(crate::i18n::tr("Duplicate Selected Slides")).clicked() { run(app, "slide.duplicate", json!({})); } - if ui.button("Slides from Outline…").clicked() { + if ui.button(crate::i18n::tr("Slides from Outline…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("outline")); } }); @@ -373,10 +383,10 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) { small(app, ui, Icon::ResetSlide, "Reset", "slide.reset", json!({})); let (r, _) = small_button_ex(ui, Icon::Section, "Section", enabled(app, "section.add"), false, true); egui::Popup::menu(&r).show(|ui| { - if ui.button("Add Section").clicked() { + if ui.button(crate::i18n::tr("Add Section")).clicked() { run(app, "section.add", json!({})); } - if ui.button("Remove All Sections").clicked() { + if ui.button(crate::i18n::tr("Remove All Sections")).clicked() { run(app, "section.removeAll", json!({})); } }); @@ -408,9 +418,9 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { dropdown(ui, "font", &font, 150.0, en, |ui| { for (k, f) in fams.iter().enumerate() { let label = if k == 0 && theme_fonts.len() == 2 { - format!("{f} (Headings)") + crate::i18n::format("{f} (Headings)", std::slice::from_ref(f)) } else if k == 1 && theme_fonts.len() == 2 { - format!("{f} (Body)") + crate::i18n::format("{f} (Body)", std::slice::from_ref(f)) } else { f.clone() }; @@ -449,7 +459,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let r = icon_toggle(ui, Icon::CharSpacing, "Character Spacing", en, false); egui::Popup::menu(&r).show(|ui| { for (label, pt) in [("Very Tight", -3.0), ("Tight", -1.5), ("Normal", 0.0), ("Loose", 3.0), ("Very Loose", 6.0)] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { run(app, "format.spacing", json!({"pt": pt})); } } @@ -463,7 +473,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ("Capitalize Each Word", "title"), ("tOGGLE cASE", "toggle"), ] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { run(app, "format.case", json!({"mode": mode})); } } @@ -528,28 +538,28 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.spacing_mut().item_spacing.x = 1.0; let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); let (m, a) = small_button_ex(ui, Icon::Bullets, "", en, b("bullets"), true); - if m.on_hover_text("Bullets").clicked() { + if m.on_hover_text(crate::i18n::tr("Bullets")).clicked() { run(app, "format.bullets", json!({})); } if let Some(a) = a { egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { + if ui.button(crate::i18n::tr("None")).clicked() { run(app, "format.bullets", json!({"on": false})); } for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] { - if ui.button(egui::RichText::new(format!("{ch} Bullet")).size(14.0)).clicked() { + if ui.button(egui::RichText::new(crate::i18n::format("{ch} Bullet", &[(ch).to_string()])).size(14.0)).clicked() { run(app, "format.bullets", json!({"on": true, "char": ch})); } } }); } let (m, a) = small_button_ex(ui, Icon::Numbering, "", en, b("numbering"), true); - if m.on_hover_text("Numbering").clicked() { + if m.on_hover_text(crate::i18n::tr("Numbering")).clicked() { run(app, "format.numbering", json!({})); } if let Some(a) = a { egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { + if ui.button(crate::i18n::tr("None")).clicked() { run(app, "format.numbering", json!({"on": false})); } for (label, sch) in [ @@ -561,7 +571,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ("a) b) c)", "alphaLcParenR"), ("a. b. c.", "alphaLcPeriod"), ] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { run(app, "format.numbering", json!({"on": true, "scheme": sch})); } } @@ -576,14 +586,15 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.lineSpacing", json!({"lines": v})); } } - if ui.button("Line Spacing Options…").clicked() { + if ui.button(crate::i18n::tr("Line Spacing Options…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); } }); let r = icon_toggle(ui, Icon::Columns, "Columns", en, false); egui::Popup::menu(&r).show(|ui| { for n in 1..=3 { - if ui.button(format!("{n} Column{}", if n > 1 { "s" } else { "" })).clicked() { + if ui.button(crate::i18n::count(n, if n == 1 { "Column" } else { "Columns" }, "стовпець", "стовпці", "стовпців")).clicked() + { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } @@ -605,7 +616,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ("Rotate all text 270°", "rotate270"), ("Stacked", "stacked"), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "format.direction", json!({"dir": d})); } } @@ -613,7 +624,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let r = icon_toggle(ui, Icon::AlignText, "Align Text", en, false); egui::Popup::menu(&r).show(|ui| { for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "format.anchor", json!({"anchor": a})); } } @@ -630,7 +641,7 @@ pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { ui.set_max_width(330.0); egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { for cat in deckcraft_geom::preset::Category::ALL { - ui.label(egui::RichText::new(cat.label()).font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr(cat.label())).font(theme::bold(12.0))); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(1.0, 1.0); for p in deckcraft_geom::preset::CATALOG.iter().filter(|p| p.category == cat) { @@ -639,7 +650,7 @@ pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); } paint_preset(ui.painter(), r.shrink(4.0), p.name, Color32::TRANSPARENT, t.text); - if resp.on_hover_text(p.label).clicked() { + if resp.on_hover_text(crate::i18n::tr(p.label)).clicked() { out = Some(p.name); } } @@ -650,7 +661,7 @@ pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { ui.painter().rect_filled(r, CornerRadius::same(3), t.hover); } paint_freeform_icon(ui.painter(), r.shrink(5.0), name, t.text); - if resp.on_hover_text(label).clicked() { + if resp.on_hover_text(crate::i18n::tr(label)).clicked() { out = Some(name); } } @@ -724,7 +735,7 @@ fn kurbo_flatten(path: &deckcraft_geom::BezPath, mut f: impl FnMut(Fl)) { fn drawing_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let en = enabled(app, "shape.insert"); - let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes"); + let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text(crate::i18n::tr("Shapes")); egui::Popup::menu(&r).show(|ui| { if let Some(p) = shapes_gallery(ui) { app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); @@ -774,15 +785,15 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color None => run(app, "shape.line", json!({"none": true})), } } - ui.menu_button("Weight", |ui| { + ui.menu_button(crate::i18n::tr("Weight"), |ui| { for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { - if ui.button(format!("{w} pt")).clicked() { + if ui.button(crate::i18n::format("{w} pt", &[(w).to_string()])).clicked() { run(app, "shape.line", json!({"width": w})); ui.close(); } } }); - ui.menu_button("Dashes", |ui| { + ui.menu_button(crate::i18n::tr("Dashes"), |ui| { for (l, d) in [ ("Solid", "solid"), ("Round Dot", "sysDot"), @@ -792,13 +803,13 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color ("Long Dash", "lgDash"), ("Long Dash Dot", "lgDashDot"), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "shape.line", json!({"dash": d})); ui.close(); } } }); - ui.menu_button("Arrows", |ui| { + ui.menu_button(crate::i18n::tr("Arrows"), |ui| { for (l, h, tl) in [ ("None", "none", "none"), ("Arrow at end", "none", "triangle"), @@ -807,7 +818,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color ("Stealth end", "none", "stealth"), ("Oval ends", "oval", "oval"), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "shape.line", json!({"head": h, "tail": tl})); ui.close(); } @@ -816,7 +827,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color } pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.menu_button("Shadow", |ui| { + ui.menu_button(crate::i18n::tr("Shadow"), |ui| { for (l, v) in [ ("No Shadow", "none"), ("Outer", "outer"), @@ -825,36 +836,36 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { ("Inner", "inner"), ("Perspective", "perspective"), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "shape.effects", json!({"shadow": v})); } } }); - ui.menu_button("Reflection", |ui| { + ui.menu_button(crate::i18n::tr("Reflection"), |ui| { for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "shape.effects", json!({"reflection": v})); } } }); - ui.menu_button("Glow", |ui| { - if ui.button("No Glow").clicked() { + ui.menu_button(crate::i18n::tr("Glow"), |ui| { + if ui.button(crate::i18n::tr("No Glow")).clicked() { run(app, "shape.effects", json!({"glow": null})); } for r in [5.0, 8.0, 11.0, 18.0] { - if ui.button(format!("{r} pt glow")).clicked() { + if ui.button(crate::i18n::format("{r} pt glow", &[(r).to_string()])).clicked() { run(app, "shape.effects", json!({"glow": r})); } } }); - ui.menu_button("Soft Edges", |ui| { + ui.menu_button(crate::i18n::tr("Soft Edges"), |ui| { for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] { - if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() { + if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { crate::i18n::format("{r} pt", &[(r).to_string()]) }).clicked() { run(app, "shape.effects", json!({"softEdges": r})); } } }); - ui.menu_button("Bevel", |ui| { + ui.menu_button(crate::i18n::tr("Bevel"), |ui| { for b in [ "none", "circle", @@ -875,7 +886,7 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { } } }); - if ui.button("Format Shape…").clicked() { + if ui.button(crate::i18n::tr("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); } } @@ -904,7 +915,7 @@ fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { _ => (c, c.lerp_to_gamma(Color32::BLACK, 0.3), Color32::WHITE), }; p.rect(rect.shrink(2.0), CornerRadius::same(3), fill, Stroke::new(1.5, stroke), egui::StrokeKind::Inside); - p.text(rect.center(), Align2::CENTER_CENTER, "Abc", theme::font(11.0), text); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Abc"), theme::font(11.0), text); if resp.hovered() { p.rect_stroke(rect, CornerRadius::same(3), Stroke::new(1.5, Tokens::get(ui.ctx()).accent), egui::StrokeKind::Inside); } @@ -919,7 +930,7 @@ fn quick_styles(app: &mut SlideApp, ui: &mut Ui) { } pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { - ui.label(egui::RichText::new("Order Objects").font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Order Objects")).font(theme::bold(12.0))); for (l, id, ic) in [ ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), ("Send to Back", "arrange.sendToBack", Icon::SendToBack), @@ -929,20 +940,20 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) { menu_item(app, ui, ic, l, id, json!({})); } ui.separator(); - ui.label(egui::RichText::new("Group Objects").font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Group Objects")).font(theme::bold(12.0))); menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); ui.separator(); - ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); - ui.menu_button("Align", |ui| align_menu(app, ui)); - ui.menu_button("Rotate", |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Position Objects")).font(theme::bold(12.0))); + ui.menu_button(crate::i18n::tr("Align"), |ui| align_menu(app, ui)); + ui.menu_button(crate::i18n::tr("Rotate"), |ui| { menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({})); menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({})); menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({})); menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({})); }); - if ui.button("Selection Pane…").clicked() { + if ui.button(crate::i18n::tr("Selection Pane…")).clicked() { let _ = app.run("view.pane", json!({"pane": "selection"})); ui.close(); } @@ -1008,7 +1019,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } ui.separator(); - if ui.button("Insert Table…").clicked() { + if ui.button(crate::i18n::tr("Insert Table…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("table")); ui.close(); } @@ -1017,7 +1028,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); let r = big_button(ui, Icon::Screenshot, "Screenshot", false); - let _ = r.on_hover_text("Screenshot (not available yet)"); + let _ = r.on_hover_text(crate::i18n::tr("Screenshot (not available yet)")); }); group(ui, |ui| { let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); @@ -1032,7 +1043,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::Chart, "Chart", enabled(app, "insert.chart")); egui::Popup::menu(&r).show(|ui| { for k in deckcraft_model::chart::ChartType::MAIN { - if ui.button(k.label()).clicked() { + if ui.button(crate::i18n::tr(k.label())).clicked() { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } } @@ -1043,7 +1054,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::Action, "Action", enabled(app, "insert.actionButton")); egui::Popup::menu(&r).show(|ui| { for k in ["back", "forward", "beginning", "end", "home", "information", "return", "movie", "document", "sound", "help", "custom"] { - if ui.button(format!("Action Button: {k}")).clicked() { + if ui.button(crate::i18n::format("Action Button: {k}", &[(k).to_string()])).clicked() { run(app, "insert.actionButton", json!({"kind": k})); } } @@ -1058,7 +1069,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::WordArt, "WordArt", enabled(app, "insert.wordArt")); egui::Popup::menu(&r).show(|ui| { for i in 0..6 { - if ui.button(format!("WordArt style {}", i + 1)).clicked() { + if ui.button(crate::i18n::format("WordArt style {}", &[(i + 1).to_string()])).clicked() { run(app, "insert.wordArt", json!({"style": i})); } } @@ -1082,8 +1093,8 @@ pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { let id = ui.id().with("tgrid"); let mut hover: (usize, usize) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, 0))); let cell = 16.0; - let label = if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".into() }; - ui.label(label); + let label = if hover.0 > 0 { crate::i18n::format("{}x{} Table", &[(hover.1).to_string(), (hover.0).to_string()]) } else { "Insert Table".into() }; + ui.label(crate::i18n::tr(&label)); let (rect, resp) = ui.allocate_exact_size(vec2(10.0 * (cell + 2.0), 8.0 * (cell + 2.0)), Sense::click()); if let Some(p) = resp.hover_pos() { let c = ((p.x - rect.min.x) / (cell + 2.0)).floor() as usize + 1; @@ -1143,7 +1154,7 @@ fn icon_library(app: &mut SlideApp, ui: &mut Ui) { ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); } paint_preset(ui.painter(), r.shrink(6.0), preset, t.text, t.text); - if resp.on_hover_text(label).clicked() { + if resp.on_hover_text(crate::i18n::tr(label)).clicked() { let size = app.session.active().map(|d| d.doc.slide_size).unwrap_or(deckcraft_model::defaults::WIDE); let _ = app.run("shape.insert", json!({"preset": preset, "rect": [size.width / 2.0 - 48.0, size.height / 2.0 - 48.0, 96, 96]})); let _ = app.run("shape.fill", json!({"color": "tx1"})); @@ -1186,11 +1197,11 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", false); - let _ = r.on_hover_text("Ink to Shape (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Ink to Shape (coming soon)")); let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); - let _ = r.on_hover_text("Ink to Text (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Ink to Text (coming soon)")); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); - let _ = r.on_hover_text("Ink to Math (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Ink to Math (coming soon)")); }); } @@ -1267,7 +1278,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { }; let (rect, resp) = ui.allocate_exact_size(vec2(48.0, 28.0), Sense::click()); ui.painter().rect(rect, CornerRadius::same(2), shade, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); - if resp.on_hover_text(format!("Style {i}")).clicked() { + if resp.on_hover_text(crate::i18n::format("Style {i}", &[(i).to_string()])).clicked() { run(app, "design.background", json!({"style": i, "all": true})); ui.close(); } @@ -1276,7 +1287,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } } }); - if ui.button("Reset Slide Background").clicked() { + if ui.button(crate::i18n::tr("Reset Slide Background")).clicked() { run(app, "design.background", json!({"reset": true})); } }); @@ -1284,13 +1295,13 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true); egui::Popup::menu(&r).show(|ui| { - if ui.button("Standard (4:3)").clicked() { + if ui.button(crate::i18n::tr("Standard (4:3)")).clicked() { run(app, "design.slideSize", json!({"preset": "standard"})); } - if ui.button("Widescreen (16:9)").clicked() { + if ui.button(crate::i18n::tr("Widescreen (16:9)")).clicked() { run(app, "design.slideSize", json!({"preset": "widescreen"})); } - if ui.button("Page Setup…").clicked() { + if ui.button(crate::i18n::tr("Page Setup…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); } }); @@ -1305,7 +1316,7 @@ pub fn paint_theme_tile(p: &egui::Painter, r: Rect, th: &deckcraft_model::Theme) let bg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Lt1)); let fg = theme::to_color32(th.colors.get(deckcraft_color::SchemeSlot::Dk1)); p.rect(r, CornerRadius::same(2), bg, Stroke::new(1.0, Color32::from_gray(200)), egui::StrokeKind::Inside); - p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, "Aa", theme::font(r.height() * 0.38), fg); + p.text(r.min + vec2(8.0, r.height() * 0.42), Align2::LEFT_CENTER, crate::i18n::tr("Aa"), theme::font(r.height() * 0.38), fg); let w = (r.width() - 16.0) / 6.0; for k in 0..6 { let c = theme::to_color32(th.colors.colors.get(4 + k).copied().unwrap_or(deckcraft_color::Rgba::BLACK)); @@ -1345,7 +1356,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { let more = widgets::drop_button(ui, "", vec2(14.0, 52.0), true); egui::Popup::menu(&more).width(480.0).show(|ui| { for cat in ["Subtle", "Exciting", "Dynamic Content"] { - ui.label(egui::RichText::new(cat).font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr(cat)).font(theme::bold(12.0))); ui.horizontal_wrapped(|ui| { for x in list.iter().filter(|x| x.2 == cat) { let (r, resp) = ui.allocate_exact_size(vec2(56.0, 58.0), Sense::click()); @@ -1367,7 +1378,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); egui::Popup::menu(&r).show(|ui| { for o in opts { - if ui.button(option_label(o)).clicked() { + if ui.button(crate::i18n::tr(&option_label(o))).clicked() { run(app, "transition.options", json!({"option": o})); } } @@ -1376,7 +1387,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { ui.horizontal(|ui| { - ui.label("Duration:"); + ui.label(crate::i18n::tr("Duration:")); let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); if let Some(v) = widgets::spinner(ui, "tdur", d, 0.25, "", 46.0) { run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); @@ -1384,14 +1395,14 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click); - if ui.checkbox(&mut click, "On Mouse Click").changed() { + if ui.checkbox(&mut click, crate::i18n::tr("On Mouse Click")).changed() { run(app, "transition.timing", json!({"onClick": click})); } }); ui.horizontal(|ui| { let after = cur.as_ref().and_then(|t| t.advance_after_ms); let mut on = after.is_some(); - if ui.checkbox(&mut on, "After:").changed() { + if ui.checkbox(&mut on, crate::i18n::tr("After:")).changed() { run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); } if let Some(v) = widgets::spinner(ui, "tafter", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { @@ -1403,12 +1414,17 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); } -fn option_label(o: &str) -> String { +pub(crate) fn option_label(o: &str) -> String { match o { "l" => "From Left".into(), "r" => "From Right".into(), "u" => "From Top".into(), - "d" => "From Bottom".into(), + "d" | "b" => "From Bottom".into(), + "t" => "From Top".into(), + "lb" | "ld" => "From Bottom Left".into(), + "rb" | "rd" => "From Bottom Right".into(), + "lt" | "lu" => "From Top Left".into(), + "rt" | "ru" => "From Top Right".into(), "horz" => "Horizontal".into(), "vert" => "Vertical".into(), "in" => "In".into(), @@ -1495,7 +1511,7 @@ pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &st } } p.rect_stroke(pic, CornerRadius::same(2), Stroke::new(1.0, Color32::from_rgb(0x9B, 0xB4, 0xDB)), egui::StrokeKind::Inside); - p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, label, theme::font(10.0), t.text); + p.text(pos2(r.center().x, r.max.y - 7.0), Align2::CENTER_CENTER, crate::i18n::tr(label), theme::font(10.0), t.text); } // ---------- Animations ---------- @@ -1560,7 +1576,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { for (cls, label) in [(AnimClass::Entrance, "Entrance"), (AnimClass::Emphasis, "Emphasis"), (AnimClass::Exit, "Exit"), (AnimClass::Path, "Motion Paths")] { - ui.label(egui::RichText::new(label).font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr(label)).font(theme::bold(12.0))); ui.horizontal_wrapped(|ui| { for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 == cls) { let (r, resp) = ui.allocate_exact_size(vec2(56.0, 54.0), Sense::click()); @@ -1580,13 +1596,13 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !opts.is_empty()); egui::Popup::menu(&r).show(|ui| { for o in opts { - if ui.button(option_label(o)).clicked() { + if ui.button(crate::i18n::tr(&option_label(o))).clicked() { run(app, "animation.options", json!({"option": o})); } } ui.separator(); for (l, b) in [("As One Object", "asOne"), ("By Paragraph", "byParagraph")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "animation.options", json!({"textBuild": b})); } } @@ -1616,13 +1632,13 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"})); let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some()); egui::Popup::menu(&r).show(|ui| { - if ui.button("On Click of… (none)").clicked() { + if ui.button(crate::i18n::tr("On Click of… (none)")).clicked() { run(app, "animation.timing", json!({"trigger": null})); } let shapes: Vec<(u32, String)> = app.session.active().map(|d| d.shapes().iter().map(|s| (s.id.0, s.name.clone())).collect()).unwrap_or_default(); for (id, name) in shapes { - if ui.button(format!("On Click of {name}")).clicked() { + if ui.button(crate::i18n::format("On Click of {name}", std::slice::from_ref(&name))).clicked() { run(app, "animation.timing", json!({"trigger": id})); } } @@ -1644,15 +1660,15 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { ui.horizontal(|ui| { - ui.label("Start:"); + ui.label(crate::i18n::tr("Start:")); let s = cur.as_ref().map(|c| match c.start { deckcraft_model::AnimStart::OnClick => "On Click", deckcraft_model::AnimStart::WithPrevious => "With Previous", deckcraft_model::AnimStart::AfterPrevious => "After Previous", }); - dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { + dropdown(ui, "astart", crate::i18n::tr(s.unwrap_or("")), 112.0, cur.is_some(), |ui| { for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "animation.timing", json!({"start": v})); ui.close(); } @@ -1660,13 +1676,13 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); }); ui.horizontal(|ui| { - ui.label("Duration:"); + ui.label(crate::i18n::tr("Duration:")); if let Some(v) = widgets::spinner(ui, "adur", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) { run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); } }); ui.horizontal(|ui| { - ui.label("Delay:"); + ui.label(crate::i18n::tr("Delay:")); if let Some(v) = widgets::spinner(ui, "adelay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) { run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); } @@ -1684,7 +1700,13 @@ pub fn paint_anim_tile(p: &egui::Painter, r: Rect, label: &str, class: AnimClass _ => Icon::AnimPath, }; icons::paint(p, Rect::from_center_size(pos2(r.center().x, r.min.y + 18.0), vec2(28.0, 28.0)), icon, t.text, !enabled); - p.text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, label, theme::font(10.0), if enabled { t.text } else { t.text_faint }); + p.text( + pos2(r.center().x, r.max.y - 8.0), + Align2::CENTER_CENTER, + crate::i18n::tr(label), + theme::font(10.0), + if enabled { t.text } else { t.text_faint }, + ); } // ---------- Slide Show / Record / Review / View ---------- @@ -1719,20 +1741,20 @@ fn slide_show(app: &mut SlideApp, ui: &mut Ui) { } } let r = big_button(ui, Icon::Record, "Record", false); - let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Recording narration needs audio capture (coming soon)")); }); group(ui, |ui| { rows(ui, |ui| { let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); - if ui.checkbox(&mut use_t, "Use Timings").changed() { + if ui.checkbox(&mut use_t, crate::i18n::tr("Use Timings")).changed() { run(app, "show.setup", json!({"useTimings": use_t})); } let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); - if ui.checkbox(&mut narr, "Play Narrations").changed() { + if ui.checkbox(&mut narr, crate::i18n::tr("Play Narrations")).changed() { run(app, "show.setup", json!({"noNarration": !narr})); } let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); - let _ = ui.checkbox(&mut mc, "Show Media Controls"); + let _ = ui.checkbox(&mut mc, crate::i18n::tr("Show Media Controls")); }); }); } @@ -1740,7 +1762,7 @@ fn slide_show(app: &mut SlideApp, ui: &mut Ui) { fn record(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::Cameo, "Cameo", false); - let _ = r.on_hover_text("Camera capture is not available yet"); + let _ = r.on_hover_text(crate::i18n::tr("Camera capture is not available yet")); if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { app.start_show(0, false); if let Some(s) = app.show.as_mut() { @@ -1766,14 +1788,14 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { big(app, ui, Icon::Spelling, "Spelling", "app.dialog", json!({"id": "spelling"})); let r = big_button(ui, Icon::Thesaurus, "Thesaurus", false); - let _ = r.on_hover_text("Thesaurus (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Thesaurus (coming soon)")); }); group(ui, |ui| { big(app, ui, Icon::Accessibility, "Check\nAccessibility", "app.dialog", json!({"id": "accessibility"})); }); group(ui, |ui| { let r = big_button(ui, Icon::Translate, "Translate", false); - let _ = r.on_hover_text("Translation needs a language model (not bundled)"); + let _ = r.on_hover_text(crate::i18n::tr("Translation needs a language model (not bundled)")); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); group(ui, |ui| { @@ -1808,22 +1830,22 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { let _ = app.run(if in_master { "view.closeMaster" } else { "view.slideMaster" }, json!({})); } let r = big_button(ui, Icon::HandoutMaster, "Handout\nMaster", false); - let _ = r.on_hover_text("Handout Master (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Handout Master (coming soon)")); let r = big_button(ui, Icon::NotesMaster, "Notes\nMaster", false); - let _ = r.on_hover_text("Notes Master (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Notes Master (coming soon)")); }); group(ui, |ui| { rows(ui, |ui| { let mut r = app.ui.ruler; - if ui.checkbox(&mut r, "Ruler").changed() { + if ui.checkbox(&mut r, crate::i18n::tr("Ruler")).changed() { app.ui.ruler = r; } let mut g = app.ui.gridlines; - if ui.checkbox(&mut g, "Gridlines").changed() { + if ui.checkbox(&mut g, crate::i18n::tr("Gridlines")).changed() { app.ui.gridlines = g; } let mut gd = app.ui.guides; - if ui.checkbox(&mut gd, "Guides").changed() { + if ui.checkbox(&mut gd, crate::i18n::tr("Guides")).changed() { app.ui.guides = gd; } }); @@ -1872,16 +1894,16 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({})); let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel); egui::Popup::menu(&r).show(|ui| { - ui.label("Change Shape"); + ui.label(crate::i18n::tr("Change Shape")); if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } }); let can_merge = enabled(app, "shape.merge"); - let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); + let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text(crate::i18n::tr("Merge Shapes")); egui::Popup::menu(&r).show(|ui| { for (op, label) in deckcraft_engine::cmd::merge::OPS { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { run(app, "shape.merge", json!({"op": op})); ui.close(); } @@ -1904,7 +1926,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { None => run(app, "shape.fill", json!({"none": true})), } } - if ui.button("Gradient…").clicked() { + if ui.button(crate::i18n::tr("Gradient…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); ui.close(); } @@ -1940,7 +1962,7 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { } let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false); egui::Popup::menu(&r).show(|ui| { - if ui.button("Shadow").clicked() { + if ui.button(crate::i18n::tr("Shadow")).clicked() { run(app, "format.textShadow", json!({})); } }); @@ -2012,18 +2034,18 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { let Some(x) = bx else { - ui.label("Height:"); - ui.label("Width:"); + ui.label(crate::i18n::tr("Height:")); + ui.label(crate::i18n::tr("Width:")); return; }; ui.horizontal(|ui| { - ui.label("Height:"); + ui.label(crate::i18n::tr("Height:")); if let Some(v) = widgets::spinner(ui, "sh", x.h / 72.0, 0.1, "\"", 54.0) { run(app, "shape.resize", json!({"h": v.max(0.0) * 72.0})); } }); ui.horizontal(|ui| { - ui.label("Width: "); + ui.label(crate::i18n::tr("Width: ")); if let Some(v) = widgets::spinner(ui, "sw", x.w / 72.0, 0.1, "\"", 54.0) { run(app, "shape.resize", json!({"w": v.max(0.0) * 72.0})); } @@ -2035,17 +2057,17 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", false); - let _ = r.on_hover_text("Remove Background (coming soon)"); + let _ = r.on_hover_text(crate::i18n::tr("Remove Background (coming soon)")); let r = big_button(ui, Icon::Corrections, "Corrections", true); egui::Popup::menu(&r).show(|ui| { for b in [-0.4, -0.2, 0.0, 0.2, 0.4] { - if ui.button(format!("Brightness {:+}%", (b * 100.0) as i32)).clicked() { + if ui.button(crate::i18n::format("Brightness {:+}%", &[format!("{:+}", (b * 100.0) as i32)])).clicked() { run(app, "picture.adjust", json!({"brightness": b})); } } ui.separator(); for c in [-0.4, -0.2, 0.0, 0.2, 0.4] { - if ui.button(format!("Contrast {:+}%", (c * 100.0) as i32)).clicked() { + if ui.button(crate::i18n::format("Contrast {:+}%", &[format!("{:+}", (c * 100.0) as i32)])).clicked() { run(app, "picture.adjust", json!({"contrast": c})); } } @@ -2053,11 +2075,11 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::ColorAdjust, "Color", true); egui::Popup::menu(&r).show(|ui| { for s in [0.0, 0.33, 0.66, 1.0, 1.5, 2.0, 3.0] { - if ui.button(format!("Saturation {}%", (s * 100.0) as i32)).clicked() { + if ui.button(crate::i18n::format("Saturation {}%", &[((s * 100.0) as i32).to_string()])).clicked() { run(app, "picture.adjust", json!({"saturation": s})); } } - if ui.button("Grayscale").clicked() { + if ui.button(crate::i18n::tr("Grayscale")).clicked() { run(app, "picture.adjust", json!({"grayscale": true})); } }); @@ -2077,24 +2099,24 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::QuickStyles, "Picture\nStyles", true); egui::Popup::menu(&r).show(|ui| { - if ui.button("Simple Frame, White").clicked() { + if ui.button(crate::i18n::tr("Simple Frame, White")).clicked() { run(app, "shape.line", json!({"color": "#FFFFFF", "width": 6})); run(app, "shape.effects", json!({"shadow": "outer"})); } - if ui.button("Drop Shadow Rectangle").clicked() { + if ui.button(crate::i18n::tr("Drop Shadow Rectangle")).clicked() { run(app, "shape.effects", json!({"shadow": "bottom"})); } - if ui.button("Rounded Diagonal Corner").clicked() { + if ui.button(crate::i18n::tr("Rounded Diagonal Corner")).clicked() { run(app, "shape.change", json!({"preset": "round2DiagRect"})); } - if ui.button("Soft Edge Rectangle").clicked() { + if ui.button(crate::i18n::tr("Soft Edge Rectangle")).clicked() { run(app, "shape.effects", json!({"softEdges": 10})); } - if ui.button("Reflected Rounded Rectangle").clicked() { + if ui.button(crate::i18n::tr("Reflected Rounded Rectangle")).clicked() { run(app, "shape.change", json!({"preset": "roundRect"})); run(app, "shape.effects", json!({"reflection": "half"})); } - if ui.button("Oval").clicked() { + if ui.button(crate::i18n::tr("Oval")).clicked() { run(app, "shape.change", json!({"preset": "ellipse"})); } }); @@ -2104,13 +2126,13 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let r = big_button(ui, Icon::Crop, "Crop", true); egui::Popup::menu(&r).show(|ui| { - ui.label("Crop to Shape"); + ui.label(crate::i18n::tr("Crop to Shape")); if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } ui.separator(); for (l, v) in [("Crop 10% each side", 0.1), ("Crop 20% each side", 0.2), ("Reset Crop", 0.0)] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); } } @@ -2129,31 +2151,31 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default(); ui.horizontal(|ui| { let mut a = flags.0; - if ui.checkbox(&mut a, "Header Row").changed() { + if ui.checkbox(&mut a, crate::i18n::tr("Header Row")).changed() { run(app, "table.options", json!({"headerRow": a})); } let mut d = flags.3; - if ui.checkbox(&mut d, "First Column").changed() { + if ui.checkbox(&mut d, crate::i18n::tr("First Column")).changed() { run(app, "table.options", json!({"firstColumn": d})); } }); ui.horizontal(|ui| { let mut b = flags.1; - if ui.checkbox(&mut b, "Total Row").changed() { + if ui.checkbox(&mut b, crate::i18n::tr("Total Row")).changed() { run(app, "table.options", json!({"totalRow": b})); } let mut e = flags.4; - if ui.checkbox(&mut e, "Last Column").changed() { + if ui.checkbox(&mut e, crate::i18n::tr("Last Column")).changed() { run(app, "table.options", json!({"lastColumn": e})); } }); ui.horizontal(|ui| { let mut c = flags.2; - if ui.checkbox(&mut c, "Banded Rows").changed() { + if ui.checkbox(&mut c, crate::i18n::tr("Banded Rows")).changed() { run(app, "table.options", json!({"bandedRows": c})); } let mut f = flags.5; - if ui.checkbox(&mut f, "Banded Columns").changed() { + if ui.checkbox(&mut f, crate::i18n::tr("Banded Columns")).changed() { run(app, "table.options", json!({"bandedColumns": f})); } }); @@ -2186,7 +2208,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { for (id, label) in &styles { let (r, resp) = ui.allocate_exact_size(vec2(48.0, 36.0), Sense::click()); paint_table_style(ui.painter(), r.shrink(2.0), id, &sc); - if resp.on_hover_text(label).clicked() { + if resp.on_hover_text(crate::i18n::tr(label)).clicked() { run(app, "table.style", json!({"style": id})); ui.close(); } @@ -2275,7 +2297,7 @@ fn table_layout(app: &mut SlideApp, ui: &mut Ui) { }); ui.horizontal(|ui| { for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).clicked() { + if ui.small_button(crate::i18n::tr(l)).clicked() { run(app, "format.anchor", json!({"anchor": a})); } } @@ -2290,7 +2312,7 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::ChangeChartType, "Change\nChart Type", true); egui::Popup::menu(&r).show(|ui| { for k in deckcraft_model::chart::ChartType::MAIN { - if ui.button(k.label()).clicked() { + if ui.button(crate::i18n::tr(k.label())).clicked() { // Replace the chart's type keeping its data. if let Some(id) = app.session.active().and_then(|d| d.selection.shapes.first().copied()) { let _ = app.run("chart.type", json!({"id": id.0, "type": format!("{k:?}")})); @@ -2318,29 +2340,29 @@ fn playback(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { let mut auto = m.as_ref().is_some_and(|x| x.autoplay); - if ui.checkbox(&mut auto, "Start Automatically").changed() { + if ui.checkbox(&mut auto, crate::i18n::tr("Start Automatically")).changed() { run(app, "media.options", json!({"autoplay": auto})); } let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); - if ui.checkbox(&mut across, "Play Across Slides").changed() { + if ui.checkbox(&mut across, crate::i18n::tr("Play Across Slides")).changed() { run(app, "media.options", json!({"acrossSlides": across})); } let mut lp = m.as_ref().is_some_and(|x| x.loop_play); - if ui.checkbox(&mut lp, "Loop until Stopped").changed() { + if ui.checkbox(&mut lp, crate::i18n::tr("Loop until Stopped")).changed() { run(app, "media.options", json!({"loop": lp})); } }); rows(ui, |ui| { let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); - if ui.checkbox(&mut hide, "Hide During Show").changed() { + if ui.checkbox(&mut hide, crate::i18n::tr("Hide During Show")).changed() { run(app, "media.options", json!({"hide": hide})); } let mut rw = m.as_ref().is_some_and(|x| x.rewind); - if ui.checkbox(&mut rw, "Rewind after Playing").changed() { + if ui.checkbox(&mut rw, crate::i18n::tr("Rewind after Playing")).changed() { run(app, "media.options", json!({"rewind": rw})); } let mut fs = m.as_ref().is_some_and(|x| x.full_screen); - if ui.checkbox(&mut fs, "Play Full Screen").changed() { + if ui.checkbox(&mut fs, crate::i18n::tr("Play Full Screen")).changed() { run(app, "media.options", json!({"fullScreen": fs})); } }); @@ -2349,7 +2371,7 @@ fn playback(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::Volume, "Volume", m.is_some()); egui::Popup::menu(&r).show(|ui| { for (l, v) in [("Low", 0.25), ("Medium", 0.5), ("High", 1.0), ("Mute", 0.0)] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "media.options", json!({"volume": v})); } } @@ -2369,7 +2391,7 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { for (l, k) in [("Content", "content"), ("Text", "text"), ("Picture", "picture"), ("Chart", "chart"), ("Table", "table"), ("Media", "media")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { run(app, "master.insertPlaceholder", json!({"kind": k})); } } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1c8e6e1eb..302b92569 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -365,7 +365,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { painter.text( pos2(full.center().x, full.min.y + 40.0), Align2::CENTER_CENTER, - "End of slide show, click to exit.", + crate::i18n::tr("End of slide show, click to exit."), theme::font(18.0), Color32::from_gray(200), ); @@ -555,7 +555,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { painter.text( pos2(bar.min.x + 14.0, bar.center().y), Align2::LEFT_CENTER, - format!("Slide {} of {}", show.state.slide + 1, doc.slides.len()), + crate::i18n::format("Slide {} of {}", &[(show.state.slide + 1).to_string(), (doc.slides.len()).to_string()]), theme::font(12.0), Color32::from_gray(200), ); @@ -564,7 +564,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { .max_rect(Rect::from_min_max(pos2(bar.max.x - 260.0, bar.min.y), bar.max)) .layout(egui::Layout::right_to_left(egui::Align::Center)), ); - if child.button("Close").clicked() { + if child.button(crate::i18n::tr("Close")).clicked() { keep = false; } if child.button("▶").clicked() { @@ -612,7 +612,15 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { painter.text( r.center(), Align2::CENTER_CENTER, - format!("Slide {:02}:{:04.1} Total {:02}:{:02}", (t / 60.0) as u32, t % 60.0, (total / 60.0) as u32, (total % 60.0) as u32), + crate::i18n::format( + "Slide {:02}:{:04.1} Total {:02}:{:02}", + &[ + format!("{:02}", (t / 60.0) as u32), + format!("{:04.1}", t % 60.0), + format!("{:02}", (total / 60.0) as u32), + format!("{:02}", (total % 60.0) as u32), + ], + ), theme::font(13.0), Color32::WHITE, ); @@ -750,7 +758,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no let mut actions = vec![]; ctx.show_viewport_immediate( egui::ViewportId::from_hash_of("deckcraft_presenter"), - egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]), + egui::ViewportBuilder::default().with_title(crate::i18n::tr("Presenter View — DeckCraft")).with_inner_size([1100.0, 700.0]), |ui, _| { egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| { ui.horizontal(|ui| { @@ -765,15 +773,19 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no .color(Color32::WHITE), ); ui.add_space(20.0); - ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200))); + ui.label( + egui::RichText::new(crate::i18n::format("Slide {} of {}", &[(cur + 1).to_string(), (doc.slides.len()).to_string()])) + .size(16.0) + .color(Color32::from_gray(200)), + ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button("End Show").clicked() { + if ui.button(crate::i18n::tr("End Show")).clicked() { *keep = false; } - if ui.button("Next ▶").clicked() { + if ui.button(crate::i18n::tr("Next ▶")).clicked() { actions.push(show.state.next(doc)); } - if ui.button("◀ Previous").clicked() { + if ui.button(crate::i18n::tr("◀ Previous")).clicked() { actions.push(show.state.prev(doc)); } }); @@ -791,14 +803,20 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no right.min + vec2(0.0, 22.0), vec2(right.width(), right.width() * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32), ); - ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170)); + ui.painter().text(right.min, Align2::LEFT_TOP, crate::i18n::tr("Next"), theme::font(13.0), Color32::from_gray(170)); match &next_tex { Some(t) => { ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40)); - ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); + ui.painter().text( + nr.center(), + Align2::CENTER_CENTER, + crate::i18n::tr("End of slide show"), + theme::font(13.0), + Color32::from_gray(170), + ); } } let notes_rect = Rect::from_min_max(pos2(right.min.x, nr.max.y + 16.0), right.max); diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index dd26dd851..0d5c975ff 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -52,7 +52,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { Stroke::new(if selected { 3.0 } else { 1.0 }, if selected { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, format!("{}", i + 1), theme::font(12.0), t.text_dim); + p.text(pos2(img.min.x, img.max.y + 12.0), Align2::LEFT_CENTER, (i + 1).to_string(), theme::font(12.0), t.text_dim); if let Some(tr) = doc.slides.get(i).and_then(|s| s.transition.as_ref()).filter(|t| t.kind != "none") { p.text(pos2(img.max.x, img.max.y + 12.0), Align2::RIGHT_CENTER, format!("★ {}", tr.kind), theme::font(10.5), t.text_faint); } @@ -137,7 +137,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let mut buf: String = child.data_mut(|d| d.get_temp(id).unwrap_or_else(|| current.clone())); let r = child.add( egui::TextEdit::multiline(&mut buf) - .hint_text("Click to add text") + .hint_text(crate::i18n::tr("Click to add text")) .desired_width(notes_rect.width()) .desired_rows(12) .frame(egui::Frame::NONE) @@ -170,7 +170,7 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { for (i, s) in doc.slides.iter().enumerate() { let title_shape = s.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).map(|x| x.id.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new(format!("{}", i + 1)).color(t.text_dim)); + ui.label(egui::RichText::new((i + 1).to_string()).color(t.text_dim)); let (r, _) = ui.allocate_exact_size(vec2(14.0, 10.0), Sense::hover()); ui.painter().rect_stroke( r, diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index a6f4eb11d..262af45f0 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -15,12 +15,20 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let n = st.doc.slides.len(); let left = match st.selection.target { - deckcraft_engine::Target::Slides => format!("Slide {} of {}", (st.selection.slide + 1).min(n.max(1)), n), - _ => "Slide Master".to_string(), + deckcraft_engine::Target::Slides => { + crate::i18n::format("Slide {} of {}", &[((st.selection.slide + 1).min(n.max(1))).to_string(), (n).to_string()]) + } + _ => crate::i18n::tr("Slide Master").to_string(), }; let p = ui.painter(); p.text(pos2(rect.min.x + 14.0, rect.center().y), Align2::LEFT_CENTER, &left, theme::font(12.0), t.text_dim); - p.text(pos2(rect.min.x + 120.0, rect.center().y), Align2::LEFT_CENTER, "English (United States)", theme::font(12.0), t.text_dim); + p.text( + pos2(rect.min.x + 120.0, rect.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr("English (United States)"), + theme::font(12.0), + t.text_dim, + ); // Transient status message. if let Some((msg, at)) = app.status.clone() { if crate::now_ms() - at < 6000.0 { @@ -93,10 +101,11 @@ fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover }); } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, t.text_dim, false); - resp.on_hover_text(tip) + resp.on_hover_text(crate::i18n::tr(tip)) } fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { + let label = crate::i18n::tr(label); let t = Tokens::get(ui.ctx()); let w = 22.0 + ui.fonts_mut(|f| f.layout_no_wrap(label.into(), theme::font(12.0), t.text).size().x) + 8.0; let (r, resp) = ui.allocate_exact_size(vec2(w, 20.0), Sense::click()); @@ -104,7 +113,7 @@ fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response { ui.painter().rect_filled(r, CornerRadius::same(4), if on { t.selected } else { t.hover }); } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 11.0, r.center().y), vec2(15.0, 15.0)), icon, t.text_dim, false); - ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, theme::font(12.0), t.text_dim); + ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr(label), theme::font(12.0), t.text_dim); resp } @@ -141,7 +150,7 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { let r = ui.add( egui::TextEdit::multiline(&mut buf) .id(id) - .hint_text("Click to add notes") + .hint_text(crate::i18n::tr("Click to add notes")) .frame(egui::Frame::NONE) .desired_width(f32::INFINITY) .desired_rows(2) diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index 84cf27554..f90cd2b5f 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -14,6 +14,7 @@ struct Key { master: usize, size: (u32, u32), edit: bool, + language: crate::i18n::Language, extra: u64, } @@ -79,6 +80,7 @@ impl Textures { master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), size, edit, + language: if edit { crate::i18n::current() } else { crate::i18n::Language::En }, extra: extra ^ grayscale as u64, }; if let Some(e) = &self.canvas @@ -88,7 +90,15 @@ impl Textures { } let t0 = crate::now_ms(); let scale = size.0 as f64 / p.slide_size.width.max(1.0); - let opts = RenderOpts { scale, edit, gif_times, threads: threads(), size: Some(size), ..Default::default() }; + let opts = RenderOpts { + scale, + edit, + gif_times, + placeholder_prompt: Some(crate::i18n::placeholder_prompt), + threads: threads(), + size: Some(size), + ..Default::default() + }; let img = deckcraft_render::render_slide(p, index, &opts); self.last_render_ms = crate::now_ms() - t0; let ci = to_color_image(&img, grayscale); @@ -120,6 +130,7 @@ impl Textures { master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0), size: (width_px, h), edit: false, + language: crate::i18n::Language::En, extra: 0, }; self.clock += 1; @@ -150,3 +161,48 @@ impl Textures { Some(tex) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switching_interface_language_refreshes_cached_hints_and_preserves_user_content() { + fn updated_canvas(ctx: &egui::Context, textures: &mut Textures, p: &Presentation, language: crate::i18n::Language) -> egui::ImageData { + crate::i18n::set_current(language); + let tex = textures.slide(ctx, p, 0, (480, 270), true, 0, false, &[]).unwrap(); + let mut delta = ctx.tex_manager().write().take_delta(); + let image = delta.set.get(&tex.id()).and_then(|images| images.last()).map(|image| image.image.clone()); + delta.clear(); + image.expect("the changed interface language must update the canvas texture") + } + + let ctx = egui::Context::default(); + let mut textures = Textures::default(); + let mut p = Presentation::default(); + let before = p.clone(); + let english = updated_canvas(&ctx, &mut textures, &p, crate::i18n::Language::En); + let ukrainian = updated_canvas(&ctx, &mut textures, &p, crate::i18n::Language::Uk); + assert!(english != ukrainian, "the default placeholder hints must change visibly"); + assert_eq!(p, before); + + for master in &mut p.masters { + for layout in &mut Arc::make_mut(master).layouts { + for shape in &mut layout.shapes { + if let Some(ph) = &mut shape.ph { + ph.has_custom_prompt = true; + shape.text = Some(deckcraft_model::TextBody::from_text("Custom {project} prompt")); + } + } + } + } + Arc::make_mut(p.slides.first_mut().unwrap()).shapes.first_mut().unwrap().text = + Some(deckcraft_model::TextBody::from_text("User File {project}")); + let before = p.clone(); + let english = updated_canvas(&ctx, &mut textures, &p, crate::i18n::Language::En); + let ukrainian = updated_canvas(&ctx, &mut textures, &p, crate::i18n::Language::Uk); + assert!(english == ukrainian, "stored text and custom prompts must retain their original wording"); + assert_eq!(p, before); + crate::i18n::set_current(crate::i18n::Language::En); + } +} diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index ebdc81d0b..285b04347 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -61,25 +61,25 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { t.text_dim, ); resp.context_menu(|ui| { - if ui.button("Rename Section…").clicked() { + if ui.button(crate::i18n::tr("Rename Section…")).clicked() { let mut d = crate::dialogs::Dialog::new("renameSection"); d.params = json!({"index": si, "name": sec.name}); app.dialog = Some(d); ui.close(); } - if ui.button("Remove Section").clicked() { + if ui.button(crate::i18n::tr("Remove Section")).clicked() { let _ = app.run("section.remove", json!({"index": si})); ui.close(); } - if ui.button("Remove Section & Slides").clicked() { + if ui.button(crate::i18n::tr("Remove Section & Slides")).clicked() { let _ = app.run("section.remove", json!({"index": si, "slides": true})); ui.close(); } - if ui.button("Move Section Up").clicked() { + if ui.button(crate::i18n::tr("Move Section Up")).clicked() { let _ = app.run("section.move", json!({"index": si, "to": si.saturating_sub(1)})); ui.close(); } - if ui.button("Move Section Down").clicked() { + if ui.button(crate::i18n::tr("Move Section Down")).clicked() { let _ = app.run("section.move", json!({"index": si, "to": si + 1})); ui.close(); } @@ -95,7 +95,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { p.text( pos2(row.min.x + 14.0, img.min.y + 2.0), Align2::CENTER_TOP, - format!("{}", i + 1), + (i + 1).to_string(), theme::font(12.0), if selected { t.text } else { t.text_dim }, ); @@ -186,7 +186,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { // Click on empty space below: new slide prompt. let (r, resp) = ui.allocate_exact_size(vec2(width, 40.0), Sense::click()); if resp.hovered() { - ui.painter().text(r.center(), Align2::CENTER_CENTER, "+ New Slide", theme::font(12.0), t.text_faint); + ui.painter().text(r.center(), Align2::CENTER_CENTER, crate::i18n::tr("+ New Slide"), theme::font(12.0), t.text_faint); } if resp.clicked() { let _ = app.run("slide.new", json!({})); @@ -202,30 +202,30 @@ pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) { ("Copy", "edit.copy", json!({"scope": "slides"})), ("Paste", "edit.paste", json!({"scope": "slides"})), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::tr(l)).clicked() { let _ = app.run(id, p); ui.close(); } } ui.separator(); - if ui.button("New Slide").clicked() { + if ui.button(crate::i18n::tr("New Slide")).clicked() { let _ = app.run("slide.new", json!({})); ui.close(); } - if ui.button("Duplicate Slide").clicked() { + if ui.button(crate::i18n::tr("Duplicate Slide")).clicked() { let _ = app.run("slide.duplicate", json!({})); ui.close(); } - if ui.button("Delete Slide").clicked() { + if ui.button(crate::i18n::tr("Delete Slide")).clicked() { let _ = app.run("slide.delete", json!({})); ui.close(); } ui.separator(); - if ui.button("Add Section").clicked() { + if ui.button(crate::i18n::tr("Add Section")).clicked() { let _ = app.run("section.add", json!({"at": i})); ui.close(); } - ui.menu_button("Layout", |ui| { + ui.menu_button(crate::i18n::tr("Layout"), |ui| { let layouts: Vec<(String, u32)> = app .session .active() @@ -238,11 +238,11 @@ pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) { } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(crate::i18n::tr("Reset Slide")).clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); } - if ui.button("Format Background…").clicked() { + if ui.button(crate::i18n::tr("Format Background…")).clicked() { let _ = app.run("view.pane", json!({"pane": "background"})); ui.close(); } @@ -298,7 +298,7 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { ); let name = match li { Some(l) => m.layouts.get(l).map(|x| x.name.clone()).unwrap_or_default(), - None => format!("{} Slide Master", m.name), + None => crate::i18n::format("{} Slide Master", std::slice::from_ref(&m.name)), }; let resp = resp.on_hover_text(name); if resp.clicked() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396e..49e492f6d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -33,6 +33,7 @@ pub fn big_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> Respon /// Large button; with `split`, the label row is a separate dropdown target (returns its response). pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option) { + let label = crate::i18n::tr(label); let t = Tokens::get(ui.ctx()); let font = theme::font(11.0); let lines: Vec<&str> = wrap_label(label); @@ -65,7 +66,7 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let label_w = ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), col).size().x); let extra = if split && last { 9.0 } else { 0.0 }; let x = rect.center().x - (label_w + extra) / 2.0; - painter.text(pos2(x, y), Align2::LEFT_TOP, l, font.clone(), col); + painter.text(pos2(x, y), Align2::LEFT_TOP, crate::i18n::tr(l), font.clone(), col); if split && last { chevron(&painter, pos2(x + label_w + 5.0, y + 7.0), 3.0, col); } @@ -111,6 +112,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte } pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option) { + let label = crate::i18n::tr(label); let t = Tokens::get(ui.ctx()); let font = theme::font(12.0); let tw = if label.is_empty() { 0.0 } else { ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0 }; @@ -138,7 +140,13 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele } icons::paint(&painter, Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(17.0, 17.0)), icon, t.text, !enabled); if !label.is_empty() { - painter.text(pos2(rect.min.x + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, if enabled { t.text } else { t.text_faint }); + painter.text( + pos2(rect.min.x + 24.0, rect.center().y), + Align2::LEFT_CENTER, + crate::i18n::tr(label), + font, + if enabled { t.text } else { t.text_faint }, + ); } if split { chevron(&painter, pos2(rect.max.x - 6.0, rect.center().y), 2.8, t.text_dim); @@ -155,7 +163,7 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) painter.rect_filled(rect, CornerRadius::same(4), c); } icons::paint(&painter, rect.shrink(3.0), icon, t.text, !enabled); - resp.on_hover_text(tip) + resp.on_hover_text(crate::i18n::tr(tip)) } /// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…). @@ -182,7 +190,7 @@ pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: if enabled { color } else { t.text_faint }, ); chevron(&painter, pos2(rect.max.x - 5.5, rect.center().y), 2.6, t.text_dim); - (main.on_hover_text(tip), arrow) + (main.on_hover_text(crate::i18n::tr(tip)), arrow) } /// A ribbon group: contents, then a thin vertical separator. @@ -252,7 +260,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - let t = Tokens::get(ui.ctx()); let mut out = None; ui.set_min_width(220.0); - ui.label(egui::RichText::new("Theme Colors").font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Theme Colors")).font(theme::bold(12.0))); let slots = [ SchemeSlot::Lt1, SchemeSlot::Dk1, @@ -275,7 +283,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Inside, ); - resp.on_hover_text(tip).clicked() + resp.on_hover_text(crate::i18n::tr(tip)).clicked() }; egui::Grid::new("theme_colors").spacing(vec2(3.0, 1.0)).show(ui, |ui| { for slot in slots { @@ -314,7 +322,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - } }); ui.add_space(4.0); - ui.label(egui::RichText::new("Standard Colors").font(theme::bold(12.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Standard Colors")).font(theme::bold(12.0))); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; for (c, name) in deckcraft_color::STANDARD_COLORS { @@ -325,7 +333,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - }); ui.separator(); if let Some(n) = none_label - && ui.button(n).clicked() + && ui.button(crate::i18n::tr(n)).clicked() { out = Some(None); } @@ -333,7 +341,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - let id = ui.id().with("hex"); let mut hex: String = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); ui.horizontal(|ui| { - ui.label("More Colors… #"); + ui.label(crate::i18n::tr("More Colors… #")); let r = ui.add(egui::TextEdit::singleline(&mut hex).desired_width(70.0).hint_text("RRGGBB")); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -371,7 +379,8 @@ pub fn gallery( if sel { painter.rect_stroke(rect.expand(1.0), CornerRadius::same(4), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); } - if resp.on_hover_text(tip(i)).clicked() { + let caption = tip(i); + if resp.on_hover_text(crate::i18n::tr(&caption)).clicked() { clicked = Some(i); } } diff --git a/docs/gaps.md b/docs/gaps.md index 6ea4130aa..917e3d785 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (localization gap narrowed after Ukrainian UI) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in @@ -21,7 +21,7 @@ revision history. | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) | | 4 | EMF/WMF and SVG pictures don't render | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) | -| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | +| 5 | Target languages beyond English missing; Ukrainian UI available | Localization | B (5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) | | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | | 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) | @@ -218,9 +218,10 @@ Detail in [hardware-parity.md](hardware-parity.md). Detail in [localization-parity.md](localization-parity.md). -- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships - 26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the - infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h). +- **Target languages beyond English missing.** English and Ukrainian now have a UI catalog and + system-language selection (#15, #30). PowerPoint for Mac ships 26 languages. Additional + translations need 3–16 h per language plus native review. Beta needs at least Simplified + Chinese, Spanish, French, German and Japanese (~30–45 h). - **No IME composition in text editing** (CJK input). 3–6 h. - **UI isn't mirrored for right-to-left languages.** 6–10 h. - **Browser build draws Arabic and Hebrew as missing-glyph boxes in exports** (#81). 1–2 h. @@ -247,5 +248,6 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Narrowed localization gap: Ukrainian UI catalog and language selection exist; other target languages, IME and RTL UI remain | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 24e308a12..8c8caf15c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,18 +1,21 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; string count measured from source, PowerPoint's localizations read from its bundle) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) - -DeckCraft's interface is English only today: strings are literals in the UI and engine code, with -no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 -(Ukrainian, open). Dimension score: **~8% ready** (estimated: English complete, slide-text script +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (Ukrainian UI catalog and language selection) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) + +DeckCraft's interface supports English and Ukrainian through `crates/ui-egui/src/i18n/uk.tsv`. +Preferences offers the system language or an explicit choice; desktop choices persist across +restarts. Menus, ribbon, command palette, panels, dialogs and presenter controls are translated, +with English fallback. Presentation content, custom placeholder prompts and command IDs keep +their original values. This addresses #15 (follow the system language) and #30 (Ukrainian). +Dimension score: **~8% ready** (estimated: English complete, slide-text script support partly there), **55–90 h** to parity plus native-speaker review. ## How the numbers are measured - **UI strings:** ~1,050 user-visible string literals (measured 2026-10-10: distinct capitalized literals in `crates/ui-egui/src`, `crates/engine/src/cmd` and `crates/model/src`, - including the 226 command labels). A catalog will refine the count; until then percentages are - against this figure. + including the 226 command labels). Ukrainian catalog tests check command, menu, gallery and + literal translation-call coverage, unique keys, placeholders and bundled Cyrillic glyphs. - **Script support** is about slide text (what users type and import), separately from the UI: bidi and shaping live in `crates/text` (UAX #9, HarfRust, UAX #14 line breaking). - **PowerPoint's languages:** `Contents/Resources/*.lproj` in PowerPoint 16.113.4: ar, cs, da, de, @@ -25,6 +28,7 @@ support partly there), **55–90 h** to parity plus native-speaker review. | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support (slide text) | Native review | Status | PowerPoint for Mac UI | Hours to full | |---|---|---|---|---|---|---|---|---| | English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 | +| Ukrainian | uk | UI catalog; coverage checked by tests | UI captions; diagnostics retain original wording | Cyrillic; bundled UI fonts tested | no | available; English fallback | no | — | | Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | renders with system CJK fonts (asset policy excludes Noto CJK from builds); UAX #14 breaking; **no IME composition**; no true vertical layout | no | none | yes | 6–10 (incl. IME) | | Spanish | es | 0 (0%) | no | full (Latin) | no | none | yes (es, es-MX) | 3–5 | | Hindi | hi | 0 (0%) | no | Devanagari shaping through HarfRust, untested; no bundled Devanagari font | no | none | **no** (Windows only) | 4–7 | @@ -37,14 +41,13 @@ support partly there), **55–90 h** to parity plus native-speaker review. | Korean | ko | 0 (0%) | no | system Hangul fonts; no IME composition | no | none | yes | 4–7 | | Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 | -Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more +Other languages shipped: **1 (Ukrainian)**. PowerPoint for Mac ships 16 more than the twelve above: Czech, Danish, Greek, Finnish, Hebrew, Hungarian, Italian, Dutch, Norwegian, Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese. -Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a -sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows -the system (#15), plural and number formatting, and an `xtask` check that every key has a -translation. IME composition (3–6 h) is counted once, under Simplified Chinese. +The UI catalog, system-language setting and Ukrainian integer-count formatting now exist. +Additional languages still need catalogs, appropriate plural/number formatting and native review. +IME composition (3–6 h) is counted once, under Simplified Chinese. ## Proofing languages @@ -56,4 +59,5 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Added Ukrainian catalog, language selection, English fallback, preserved custom prompts and bundled-font coverage | | 2026-10-10 | major | Created: twelve-language table, ~1,050 UI strings measured, PowerPoint for Mac's 26 languages from its bundle | diff --git a/docs/roadmap.md b/docs/roadmap.md index da2757376..42df29f21 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (localization infrastructure and Ukrainian UI available) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -29,7 +29,7 @@ points and lead the beta plan. 2. **PPTX on real decks:** build an owned corpus, round-trip checks in CI, fix what breaks; comment threads, media options and rewind on save (#70, #74, #77); EMF/WMF and SVG. 31–55 h. 3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS - open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h. + open-with (#68), font lookup (#80). 4–8 h. Ukrainian UI and its catalog are available (#34). ## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents) @@ -43,7 +43,7 @@ Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` | 3 | PPTX round-trip losses (#70, #74, #77), `.potx`/`.ppsx`/`.pptm` content types, embedded fonts | 8–15 | File formats → 80% | | 4 | EMF/WMF and SVG pictures | 8–14 | File formats, Pictures | | 5 | Vector PDF, printing | 10–16 | File formats, Files | -| 6 | Localization infrastructure + zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% | +| 6 | Additional catalogs for zh-Hans, es, fr, de, ja (with IME) | 27–46 | Localization 8 → ~40% | | 7 | Font weights (#69), image paste (#53), Tab in text (#78) | 6–11 | Features | | 8 | Charts: data grid, elements | 6–10 | Features | | 9 | Animation Pane timeline, preview, effect options | 5–8 | Features | @@ -109,5 +109,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Recorded Ukrainian UI and catalog infrastructure; beta localization work now focuses on additional languages and IME | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c27d893d..f0d16be1e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # DeckCraft vs Microsoft PowerPoint: parity assessment -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (localization evidence updated for Ukrainian UI) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?". [ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall; @@ -71,7 +71,7 @@ The weights are what those users actually depend on, written down so the total c | Performance | 5% | ~55% | 10–20 | [hardware-parity.md](hardware-parity.md) | Unmeasured against PowerPoint. Slides rasterize on the CPU (`vello_cpu`); Morph re-aligns outlines every frame (#67) | | Hardware | 5% | ~40% | 25–45 | [hardware-parity.md](hardware-parity.md) | GPU-composited window (wgpu), but CPU slide raster, software-only video decode, presenter view not placed on the second display, no pen pressure, no pinch zoom | | Platforms | 5% | ~80% | 5–10 | [ROADMAP.md](../ROADMAP.md) | Ahead in reach: macOS, Windows x64/x86/arm64, Linux (5 formats), FreeBSD and the web, all from one release workflow. Held back by the Windows GPU crashes and Linux/Windows clipboard gaps | -| Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English only; no string catalog. PowerPoint for Mac ships 26 languages. Bidi and Arabic shaping in slide text exist | +| Localization | 5% | ~8% | 55–90 + native review | [localization-parity.md](localization-parity.md) | English and Ukrainian UI catalog with system-language selection. PowerPoint for Mac ships 26 languages; Ukrainian is outside that count. Bidi and Arabic shaping in slide text exist | | Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes vs PowerPoint's large template gallery; no add-ins or VBA (out of scope by policy) | | AI features | 2% | ~10% | 20–40 (owner decision on models) | [gaps.md](gaps.md) | No Designer, Copilot, Speaker Coach, live subtitles or Translate (all Microsoft cloud services). Agents drive every command over MCP instead | | **Total** | 100% | **~55%** (Σ weight × ready = 55.3) | **~365–650** | | | @@ -276,6 +276,7 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-10 | minor | Updated localization evidence for Ukrainian UI; overall readiness estimates stay unchanged | | 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages | | 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged | | 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |