diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index c8137dfbf..bf713e9ef 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,6 +27,8 @@ 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 | +| `crates/ui-egui/src/i18n/pt-br.tsv` | @RicSchonfelder, AI-assisted | Contributor-original Brazilian Portuguese translation in this repository | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Brazilian Portuguese 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..75e634f61 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-12 · **Change:** minor (Brazilian Portuguese UI catalog added alongside Ukrainian) · **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,11 @@ 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, Ukrainian and Brazilian Portuguese: Preferences offers System language, English, +Українська and Português (Brasil). Matching OS/browser locales select a catalog 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 +106,9 @@ 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) and Brazilian Portuguese (pt-BR)**, with English fallback +and bundled glyph coverage. PowerPoint for Mac ships 26 languages; additional catalogs and native +review remain. ## Upcoming @@ -113,7 +118,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 +133,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-12 | Brazilian Portuguese UI catalog (`pt-br.tsv`) added on the Ukrainian infrastructure | +| 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 +146,8 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Recorded the Brazilian Portuguese catalog in the language 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..61f1fd9ad 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", "презентації|презентацій|презентацій", "apresentação|apresentações") + }; + 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,17 @@ 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" }, + "слайд|слайди|слайдів", + "slide|slides" + ) + )); + if ui.small_button(crate::i18n::tr("Delete")).clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); @@ -606,11 +638,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 +663,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 +699,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 +710,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 +728,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 +747,39 @@ 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 _ = app.run("app.language", json!({"language": 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..897de0669 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,447 @@ +//! 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, + PtBr, +} + +impl Language { + pub const ALL: [Self; 4] = [Self::System, Self::En, Self::Uk, Self::PtBr]; + + pub fn label(self) -> &'static str { + match self { + Self::System => tr("System language"), + Self::En => "English", + Self::Uk => "Українська", + Self::PtBr => "Português (Brasil)", + } + } + + /// The `app.language` code and the saved preference value. + pub fn code(self) -> &'static str { + match self { + Self::System => "system", + Self::En => "en", + Self::Uk => "uk", + Self::PtBr => "pt-BR", + } + } + + pub fn from_locale(locale: &str) -> Self { + let language = locale.split(['-', '_', '.', '@']).next().unwrap_or(""); + if language.eq_ignore_ascii_case("uk") { + Self::Uk + } else if language.eq_ignore_ascii_case("pt") { + Self::PtBr + } 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 UK_SOURCE: &str = include_str!("i18n/uk.tsv"); +const PT_BR_SOURCE: &str = include_str!("i18n/pt-br.tsv"); +static UK: OnceLock> = OnceLock::new(); +static PT_BR: OnceLock> = OnceLock::new(); + +fn parse(source: &str) -> HashMap { + source + .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() +} + +/// The catalog of a language that has one. English — and a `System` preference before it resolves — +/// has none, so its text stays as written. +pub fn catalog(language: Language) -> Option<&'static HashMap> { + match language { + Language::Uk => Some(UK.get_or_init(|| parse(UK_SOURCE))), + Language::PtBr => Some(PT_BR.get_or_init(|| parse(PT_BR_SOURCE))), + _ => None, + } +} + +fn active_catalog() -> Option<&'static HashMap> { + catalog(CURRENT.get()) +} + +/// Exact lookup at an explicit interface boundary; unknown text falls back to English. +pub fn tr(text: &str) -> &str { + active_catalog().and_then(|c| c.get(text)).map(String::as_str).unwrap_or(text) +} + +/// Context-specific interface label with the original English source as fallback. +pub fn tr_context<'a>(source: &'a str, key: &str) -> &'a str { + active_catalog().and_then(|c| c.get(key)).map(String::as_str).unwrap_or(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 +} + +/// Integer count of a noun. `english` is the English form the caller already chose; `uk` carries the +/// three Ukrainian forms (`one|few|many`) and `pt` the two Portuguese forms (`one|other`), both +/// pipe-separated. A language without forms of its own falls back to English. +pub fn count(n: usize, english: &str, uk: &str, pt: &str) -> String { + let selected = match CURRENT.get() { + Language::Uk => Some(( + uk, + match (n % 10, n % 100) { + (1, last) if last != 11 => 0, + (2..=4, last) if !(12..=14).contains(&last) => 1, + _ => 2, + }, + )), + Language::PtBr => Some((pt, usize::from(n > 1))), + _ => None, + }; + let word = selected.and_then(|(forms, index)| forms.split('|').nth(index)).unwrap_or(english); + format!("{n} {word}") +} + +/// Recovery notice includes the Ukrainian accusative forms and the Portuguese ones. +pub fn recovered(n: usize) -> String { + let amount = count( + n, + if n == 1 { "unsaved presentation" } else { "unsaved presentations" }, + "незбережену презентацію|незбережені презентації|незбережених презентацій", + "apresentação não salva|apresentações não salvas", + ); + 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 language in [Language::Uk, Language::PtBr] { + let catalog = catalog(language).unwrap(); + 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), "{language:?}: missing effect option: {option}: {label}"); + } + } + let missing: Vec<_> = required.iter().filter(|key| !catalog.contains_key(**key)).collect(); + assert!(missing.is_empty(), "{language:?}: missing visible metadata: {missing:?}"); + } + } + + /// Every literal passed to `i18n::tr` / `i18n::format` in the UI crates and the apps. + fn literal_translation_keys() -> Vec { + let mut keys = vec![]; + 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(); + keys.push(key); + rest = &tail[end + 1..]; + } + } + } + keys + } + + #[test] + fn every_literal_translation_call_has_a_catalog_entry() { + for language in [Language::Uk, Language::PtBr] { + let catalog = catalog(language).unwrap(); + for key in literal_translation_keys() { + assert!(catalog.contains_key(&key), "{language:?}: missing literal translation: {key}"); + } + } + } + + #[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(Language::Uk) + .unwrap() + .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}"); + } + } + + #[test] + fn brazilian_portuguese_lookup_counts_and_fallbacks() { + set_current(Language::PtBr); + assert_eq!(tr("File"), "Arquivo"); + assert_eq!(tr("Insert"), "Inserir"); + assert_eq!(tr_context("Insert", "@ribbon:Insert"), "Inserir"); + assert_eq!(tr("a user-defined slide title"), "a user-defined slide title"); + assert_eq!(count(1, "slide", "слайд|слайди|слайдів", "slide|slides"), "1 slide"); + assert_eq!(count(3, "slides", "слайд|слайди|слайдів", "slide|slides"), "3 slides"); + for (n, word) in + [(0, "apresentação não salva"), (1, "apresentação não salva"), (2, "apresentações não salvas"), (11, "apresentações não salvas")] + { + let text = recovered(n); + assert!(text.contains(word), "{n}: {text}"); + } + set_current(Language::En); + assert_eq!(tr("File"), "File"); + } + + #[test] + fn bundled_document_font_covers_the_brazilian_portuguese_catalog() { + 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(Language::PtBr).unwrap().values().flat_map(|value| value.chars()).filter(|c| ('\u{00c0}'..='\u{00ff}').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), ("pt-BR", Language::PtBr)] { + 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 catalogs_are_unique_complete_and_preserve_placeholders() { + for (code, source) in [("uk", UK_SOURCE), ("pt-BR", PT_BR_SOURCE)] { + let mut seen = std::collections::HashSet::new(); + for (line_no, line) in source.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let (source, text) = line.split_once('\t').unwrap(); + assert!(seen.insert(source), "{code}: duplicate on {}: {source}", line_no + 1); + assert!(!text.trim().is_empty(), "{code}: empty translation: {source}"); + assert_eq!(placeholders(source), placeholders(text), "{code}: 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", "слайд|слайди|слайдів", "slide|slides"), format!("{n} {word}")); + } + set_current(Language::En); + } +} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv new file mode 100644 index 000000000..49a640cf2 --- /dev/null +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -0,0 +1,1235 @@ +# Brazilian Portuguese interface catalog. English source and translation separated by a tab; +# \n denotes a line break. +# Contributor-original translation, MIT OR Apache-2.0. + Fixed date (empty = update automatically): Data fixa (vazio = atualizar automaticamente): + Footer text: Texto do rodapé: + Present Apresentar + pt pt + — Edited — Editado +"Not Allowed" Symbol Símbolo "Proibido" +% of all commits % de todos os commits ++ New Comment + Novo comentário ++ New Slide + Novo slide ++ Row + Linha ++ Series + Série ++Bin +bin. +100% Stacked Bar Barra 100% empilhada +100% Stacked Column Coluna 100% empilhada +35mm Slides Slides 35 mm +@ribbon:Home Início +@ribbon:Insert Inserir +@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {} @{}: {} PRs, {} commits, +{} / −{} linhas (Δ {}), +{} / −{} ativos binários, {} – {} +A3 Paper (297x420 mm) Papel A3 (297x420 mm) +A4 Paper (210x297 mm) Papel A4 (210x297 mm) +Aa Aa +Abc Abc +About Sobre +About DeckCraft Sobre o DeckCraft +Accent 1 Realce 1 +Accent 2 Realce 2 +Accent 3 Realce 3 +Accent 4 Realce 4 +Accent 5 Realce 5 +Accent 6 Realce 6 +Accessibility Checker Verificador de Acessibilidade +Across Sobre +Action Ação +Action Button: Custom Botão de ação: Personalizado +Action Button: Document Botão de ação: Documento +Action Button: Get Information Botão de ação: Obter informações +Action Button: Go Back Botão de ação: Voltar +Action Button: Go Back or Previous Botão de ação: Voltar ou anterior +Action Button: Go Forward or Next Botão de ação: Avançar ou próximo +Action Button: Go Home Botão de ação: Ir para o início +Action Button: Go to Beginning Botão de ação: Ir para o começo +Action Button: Go to End Botão de ação: Ir para o fim +Action Button: Help Botão de ação: Ajuda +Action Button: Sound Botão de ação: Som +Action Button: Video Botão de ação: Vídeo +Action Button: {k} Botão de ação: {k} +Action Buttons Botões de ação +Add Animation Adicionar animação +Add Section Adicionar seção +Add\nAnimation Adicionar\nAnimação +Address: Endereço: +Adjust Shape Ajustar forma +After Previous Após anterior +After: Depois: +Airplane Avião +Align Alinhar +Align Bottom Alinhar embaixo +Align Center Centralizar +Align Left Alinhar à esquerda +Align Middle Alinhar ao meio +Align Right Alinhar à direita +Align Text Alinhar texto +Align Top Alinhar em cima +Alt Text Texto Alt +Alt+ Alt+ +Alt+F9 Alt+F9 +Alt\nText Texto\nAlt +An open-source, clean-room project. Not affiliated with Microsoft. Um projeto de código aberto, clean-room. Sem vínculo com a Microsoft. +Angle Ângulo +Animation Gallery Galeria de animações +Animation Pane Painel de animação +Animation Styles Estilos de animação +Animation\nPane Painel de\nAnimação +Animations Animações +Animation Painter Pincel de animação +Apply Animation Aplicar animação +Play/Pause GIF Reproduzir/pausar GIF +Animations on Slide Animações no slide +Appear Aparecer +Apply Aplicar +Apply To All Aplicar a todos +Apply to All Aplicar a todos +Apply\nTo All Aplicar a\nTodos +Arc Arco +Arcs Arcos +Area Área +Arrange Organizar +Arrow Seta +Arrow at end Seta no fim +Arrow at start Seta no início +Arrow: Bent Seta: curva +Arrow: Bent-Up Seta: curva para cima +Arrow: Chevron Seta: divisa +Arrow: Down Seta: para baixo +Arrow: Left Seta: para a esquerda +Arrow: Left-Right Seta: esquerda-direita +Arrow: Left-Right-Up Seta: esquerda-direita-cima +Arrow: Left-Up Seta: esquerda-cima +Arrow: Notched Right Seta: direita entalhada +Arrow: Pentagon Seta: pentágono +Arrow: Quad Seta: quádrupla +Arrow: Right Seta: para a direita +Arrow: Striped Right Seta: direita listrada +Arrow: U-Turn Seta: retorno +Arrow: Up Seta: para cima +Arrow: Up-Down Seta: cima-baixo +Arrows Setas +Arrows at both ends Setas nas duas pontas +As One Object Como um único objeto +Audio Áudio +Audio from File… Áudio do arquivo… +AutoCorrect as you type Autocorreção ao digitar +Autofit Ajustar automaticamente +Automatic Automático +B4 (ISO) Paper (250x353 mm) Papel B4 (ISO) (250x353 mm) +B5 (ISO) Paper (176x250 mm) Papel B5 (ISO) (176x250 mm) +Background Plano de fundo +Background Styles Estilos de plano de fundo +Background\nStyles Estilos de\nPlano de fundo +Backspace Backspace +Banded Columns Colunas em faixas +Banded Rows Linhas em faixas +Banner Faixa +Basic Cycle Ciclo básico +Basic List Lista básica +Basic Process Processo básico +Basic Shapes Formas básicas +Before text (in): Antes do texto (pol): +Bevel Chanfro +Binary assets added Ativos binários adicionados +Binary assets removed Ativos binários removidos +Black Left Preto à esquerda +Black Pen Caneta preta +Black Right Preto à direita +Blinds Persianas +Block Arc Arco em bloco +Block Arrows Setas em bloco +Blue Azul +Blue Pen Caneta azul +Bold Negrito +Bold Flash Piscada em negrito +Both Ambos +Bottom Embaixo +Bounce Saltar +Box Caixa +Box In Caixa para dentro +Box Out Caixa para fora +Box and Whisker Caixa e bigodes +Brightness Brilho +Brightness {:+}% Brilho {:+}% +Bring Forward Trazer para frente +Bring to Front Trazer para a frente +Bring\nForward Trazer para\nFrente +Browsed at a kiosk (full screen) Exibido em quiosque (tela inteira) +Browsed by an individual (window) Exibido por uma pessoa (janela) +Bubble Balão +Bullets Marcadores +Burst Explosão +By Paragraph Por parágrafo +Callout: Down Arrow Balão: seta para baixo +Callout: Left Arrow Balão: seta para a esquerda +Callout: Line Balão: linha +Callout: Right Arrow Balão: seta para a direita +Callout: Up Arrow Balão: seta para cima +Callouts Balões +Cameo Camafeu +Camera capture is not available yet A captura pela câmera ainda não está disponível +Cancel Cancelar +Capitalize Each Word Colocar Em Maiúscula Cada Palavra +Caps Maiúsculas +Center Centralizar +Centered Centralizado +Change Case Alterar maiúsculas/minúsculas +Change Chart Type Alterar tipo de gráfico +Change Picture Alterar imagem +Change Shape Alterar forma +Change\nChart Type Alterar\nTipo de Gráfico +Character Spacing Espaçamento entre caracteres +Characters Caracteres +Chart Gráfico +Chart Data Dados do gráfico +Chart Design Design do gráfico +Chart Elements Elementos do gráfico +Chart… Gráfico… +Check Accessibility Verificar Acessibilidade +Check\nAccessibility Verificar\nAcessibilidade +Checkerboard Tabuleiro +Chevron Divisa +Choose a SmartArt Graphic Escolha um gráfico SmartArt +Chord Corda +Circle Círculo +Circle In Círculo para dentro +Circle Out Círculo para fora +Clear All Formatting Limpar toda a formatação +Click a symbol to insert it at the insertion point. Clique em um símbolo para inseri-lo no ponto de inserção. +Click icon to add SmartArt graphic Clique no ícone para adicionar um gráfico SmartArt +Click icon to add chart Clique no ícone para adicionar um gráfico +Click icon to add media Clique no ícone para adicionar mídia +Click icon to add picture Clique no ícone para adicionar uma imagem +Click icon to add table Clique no ícone para adicionar uma tabela +Click to add first slide Clique para adicionar o primeiro slide +Click to add notes Clique para adicionar anotações +Click to add subtitle Clique para adicionar subtítulo +Click to add text Clique para adicionar texto +Click to add title Clique para adicionar título +Click to edit Master text styles Clique para editar os estilos de texto do Mestre +Click to edit Master title style Clique para editar o estilo de título do Mestre +Clock Relógio +Clockwise Sentido horário +Close Fechar +Close Master View Fechar Modo de Exibição do Mestre +Close\nMaster View Fechar\nModo Mestre +Cloud Nuvem +Clustered Bar Barra agrupada +Clustered Column Coluna agrupada +Cmd+ Cmd+ +Cmd+, Cmd+, +Cmd+- Cmd+- +Cmd+= Cmd+= +Cmd+F1 Cmd+F1 +Cmd+Shift+P Cmd+Shift+P +Collapse the Ribbon Recolher a faixa de opções +Color Cor +Color Pulse Pulso de cor +Colors Cores +Columns Colunas +Comb Pente +Combo Combinação +Command Palette Paleta de comandos +Comment Comentário +Comments Comentários +Commits Commits +Community Links Links da comunidade +Company Empresa +Complementary Color Cor complementar +Connect Shapes Conectar formas +Connection Sites Pontos de conexão +Connector: Curved Conector: curvo +Connector: Elbow Conector: em cotovelo +Connector: Elbow (one bend) Conector: em cotovelo (uma curva) +Content Conteúdo +Contrast Contraste +Contrast {:+}% Contraste {:+}% +Contributors Colaboradores +Conveyor Esteira +Copy Copiar +Corrections Correções +Couldn't save AutoRecover information: {e} Não foi possível salvar as informações de AutoRecuperação: {e} +Counter Clockwise Sentido anti-horário +Cover Cobertura +Create Criar +Crop Recortar +Crop 10% each side Recortar 10% de cada lado +Crop 20% each side Recortar 20% de cada lado +Crop to Shape Recortar para a forma +Cross Cruz +Crush Esmagar +Ctrl+ Ctrl+ +Cube Cubo +Currency Moeda +Curtains Cortinas +Custom Personalizado +Custom Path Trajeto personalizado +Custom Show Apresentação personalizada +Custom Show 1 Apresentação personalizada 1 +Custom Shows Apresentações personalizadas +Custom Slide Show Apresentação de slides personalizada +Custom\nShow Apresentação\nPersonalizada +Cut Recortar +Cylinder Cilindro +Dark Blue Azul-escuro +Dark Mode Modo escuro +Dark Red Vermelho-escuro +Dark appearance Aparência escura +Dark\nMode Modo\nEscuro +Darken Escurecer +Dash Traço +Dash Dot Traço e ponto +Dashes Traços +Database Banco de dados +Date Data +Date & Time Data e hora +Date &\nTime Data e\nHora +Date and Time Data e hora +Date and time Data e hora +Decagon Decágono +Decision Decisão +DeckCraft DeckCraft +Decrease Font Size Diminuir tamanho da fonte +Decrease List Level Diminuir nível da lista +Delay: Atraso: +Delete Excluir +Delete Columns Excluir colunas +Delete Comment Excluir comentário +Delete Layout Excluir layout +Delete Rows Excluir linhas +Delete Slide Excluir slide +Delete Table Excluir tabela +Delete Text Excluir texto +Desaturate Dessaturar +Deselect Desmarcar +Design Design +Design Ideas Ideias de design +Design\nIdeas Ideias de\nDesign +Deutsch Deutsch +Diagonal Stripe Faixa diagonal +Diamond Losango +Diamond In Losango para dentro +Diamond Out Losango para fora +Direction Direção +Disappear Desaparecer +Discard Recovered Presentations Descartar apresentações recuperadas +Display name Nome de exibição +Dissolve Dissolver +Dissolve In Dissolver para dentro +Dissolve Out Dissolver para fora +Distribute Distribuir +Distribute Columns Distribuir colunas +Distribute Horizontally Distribuir horizontalmente +Distribute Rows Distribuir linhas +Distribute Vertically Distribuir verticalmente +Division Sign Sinal de divisão +Do not Autofit Não ajustar automaticamente +Do you want to save the changes you made to {what}? Deseja salvar as alterações feitas em {what}? +Document Documento +Dodecagon Dodecágono +Don't Save Não salvar +Don't show on title slide Não mostrar no slide de título +Done Concluído +Donut Rosca +Doors Portas +Double Brace Chave dupla +Double Bracket Colchete duplo +Double Wave Onda dupla +Doughnut Rosca +Down Para baixo +Drape Drapeado +Draw Desenhar +Drop Shadow Rectangle Retângulo com sombra projetada +Duplicate Duplicar +Duplicate Selected Slides Duplicar slides selecionados +Duplicate Slide Duplicar slide +Duration: Duração: +Dynamic Content Conteúdo dinâmico +Edit Editar +Edit Alt Text… Editar texto Alt… +Edit Data Editar dados +Edit Shape Editar forma +Edit Text Editar texto +Edit the data: first row = series names, first column = categories. Edite os dados: primeira linha = nomes das séries, primeira coluna = categorias. +Edit\nData Editar\nDados +Edit\nShape Editar\nForma +Editing Editando +Effect Options Opções de efeito +Effect\nOptions Opções de\nEfeito +Effects Efeitos +Email Address Endereço de e-mail +Email address: Endereço de e-mail: +Emphasis Ênfase +End Fim +End Show Encerrar apresentação +End of slide show Fim da apresentação de slides +End of slide show, click to exit. Fim da apresentação de slides, clique para sair. +End trim (s): Corte final (s): +English (United Kingdom) Inglês (Reino Unido) +English (United States) Inglês (Estados Unidos) +Enter Enter +Entrance Entrada +Equals Igual +Equation Equação +Equation Shapes Formas de equação +Eraser Borracha +Escape Esc +Español Español +Exciting Empolgante +Exit Sair +Expand Expandir +Explosion: 14 Points Explosão: 14 pontas +Explosion: 8 Points Explosão: 8 pontas +Export Exportar +Export the presentation as: Exportar a apresentação como: +Exported {path} Exportado {path} +Export… Exportar… +Fade Esmaecer +Fade in (s): Esmaecer entrada (s): +Fade out (s): Esmaecer saída (s): +Fall Over Tombar +Ferris Wheel Roda-gigante +File Arquivo +Fill Preenchimento +Fill & Line Preenchimento e linha +Fill Color Cor do preenchimento +Filled Radar Radar preenchido +Find… Localizar… +First Primeiro +First Column Primeira coluna +First Slide Primeiro slide +First commit Primeiro commit +Fit Ajustar +Fit slide to current window Ajustar slide à janela atual +Fit to Window Ajustar à janela +Fit to\nWindow Ajustar à\nJanela +Flash Flash +Flip Inverter +Flip Horizontal Inverter horizontalmente +Flip Vertical Inverter verticalmente +Float In Flutuar para dentro +Float Out Flutuar para fora +Flowchart Fluxograma +Flowchart: Alternate Process Fluxograma: processo alternativo +Flowchart: Card Fluxograma: cartão +Flowchart: Collate Fluxograma: intercalar +Flowchart: Connector Fluxograma: conector +Flowchart: Data Fluxograma: dados +Flowchart: Decision Fluxograma: decisão +Flowchart: Delay Fluxograma: atraso +Flowchart: Direct Access Storage Fluxograma: armazenamento de acesso direto +Flowchart: Display Fluxograma: exibição +Flowchart: Document Fluxograma: documento +Flowchart: Extract Fluxograma: extrair +Flowchart: Internal Storage Fluxograma: armazenamento interno +Flowchart: Magnetic Disk Fluxograma: disco magnético +Flowchart: Manual Input Fluxograma: entrada manual +Flowchart: Manual Operation Fluxograma: operação manual +Flowchart: Merge Fluxograma: mesclar +Flowchart: Multidocument Fluxograma: multidocumento +Flowchart: Off-page Connector Fluxograma: conector fora da página +Flowchart: Or Fluxograma: ou +Flowchart: Predefined Process Fluxograma: processo predefinido +Flowchart: Preparation Fluxograma: preparação +Flowchart: Process Fluxograma: processo +Flowchart: Punched Tape Fluxograma: fita perfurada +Flowchart: Sequential Access Storage Fluxograma: armazenamento de acesso sequencial +Flowchart: Sort Fluxograma: classificar +Flowchart: Stored Data Fluxograma: dados armazenados +Flowchart: Summing Junction Fluxograma: junção de soma +Flowchart: Terminator Fluxograma: terminador +Fly In Voar para dentro +Fly Out Voar para fora +Fly Through Voar através +Folded Corner Canto dobrado +Follow the layout Seguir o layout +Followed Hyperlink Hiperlink visitado +Font Fonte +Font Color Cor da fonte +Font Size Tamanho da fonte +Fonts Fontes +Footer Rodapé +Foreground Primeiro plano +Format Formatar +Format Background Formatar plano de fundo +Format Background… Formatar plano de fundo… +Format Painter Pincel de formatação +Format Painter (double-click to keep it on) Pincel de formatação (clique duas vezes para manter ativo) +Format Pane Painel de formatação +Format Shape Formatar forma +Format Shape… Formatar forma… +Format\nBackground Formatar\nPlano de Fundo +Format\nPane Painel de\nFormatação +Formatting State Estado da formatação +Fracture Fragmentar +Frame Moldura +Français Français +Freeform Forma livre +From De +From Beginning Do começo +From Bottom De baixo +From Bottom Left Do canto inferior esquerdo +From Bottom Right Do canto inferior direito +From Current Slide Do slide atual +From Left Da esquerda +From Right Da direita +From Top De cima +From Top Left Do canto superior esquerdo +From Top Right Do canto superior direito +From bottom left Do canto inferior esquerdo +From bottom right Do canto inferior direito +From center Do centro +From top left Do canto superior esquerdo +From top right Do canto superior direito +From\nBeginning Do\nComeço +From\nCurrent Slide Do slide\nAtual +Full Completo +Full Page Slides Slides de página inteira +Funnel Funil +Gallery Galeria +General Geral +Get Text Obter texto +Glitter Purpurina +Glow Brilho +Go to Ir para +Go to Slide Ir para o slide +Grab bag Diversos +Gradient fill Preenchimento com gradiente +Gradient stops Paradas de gradiente +Gradient… Gradiente… +Grayscale Escala de cinza +Greek Grego +Green Verde +Gridlines Linhas de grade +Group Agrupar +Group Objects Agrupar objetos +Grow & Turn Crescer e girar +Grow/Shrink Crescer/reduzir +Guides Guias +Half Metade +Half Frame Meia moldura +Handout Master Mestre de folhetos +Handout Master (coming soon) Mestre de folhetos (em breve) +Handout\nMaster Mestre de\nFolhetos +Handouts (1 slide per page) Folhetos (1 slide por página) +Handouts (2 slides per page) Folhetos (2 slides por página) +Handouts (3 slides per page) Folhetos (3 slides por página) +Handouts (4 slides per page) Folhetos (4 slides por página) +Handouts (6 slides per page) Folhetos (6 slides por página) +Handouts (9 slides per page) Folhetos (9 slides por página) +Header Cabeçalho +Header & Footer Cabeçalho e rodapé +Header &\nFooter Cabeçalho e\nRodapé +Header Row Linha de cabeçalho +Header and Footer Cabeçalho e rodapé +Header and Footer… Cabeçalho e rodapé… +Heart Coração +Height Altura +Height (in): Altura (pol): +Height: Altura: +Help Ajuda +Heptagon Heptágono +Hexagon Hexágono +Hide All Ocultar tudo +Hide Background Graphics Ocultar elementos gráficos do plano de fundo +Hide During Show Ocultar durante a apresentação +Hide Slide Ocultar slide +Hide background graphics Ocultar elementos gráficos do plano de fundo +Hide\nSlide Ocultar\nSlide +Hierarchy Hierarquia +High Alta +Highlighter Marca-texto +Histogram Histograma +Home Início +Honeycomb Favo de mel +Horizontal Horizontal +Horz In Horiz. para dentro +Horz Out Horiz. para fora +How would you describe this object and its context to someone who is blind or has low vision? Como você descreveria este objeto e seu contexto para alguém cego ou com baixa visão? +Hyperlink Hiperlink +Icons Ícones +In Em +In Bounce Entrada com salto +Include hidden slides Incluir slides ocultos +Increase Font Size Aumentar tamanho da fonte +Increase List Level Aumentar nível da lista +Ink to Math Tinta em matemática +Ink to Math (coming soon) Tinta em matemática (em breve) +Ink to Shape Tinta em forma +Ink to Shape (coming soon) Tinta em forma (em breve) +Ink to Text Tinta em texto +Ink to Text (coming soon) Tinta em texto (em breve) +Ink to\nMath Tinta em\nMatemática +Ink to\nShape Tinta em\nForma +Ink to\nText Tinta em\nTexto +Inner Interno +Insert Inserir +Insert Above Inserir acima +Insert Below Inserir abaixo +Insert Hyperlink Inserir hiperlink +Insert Layout Inserir layout +Insert Left Inserir à esquerda +Insert Placeholder Inserir espaço reservado +Insert Right Inserir à direita +Insert Table Inserir tabela +Insert Table… Inserir tabela… +Insert picture from file… Inserir imagem do arquivo… +Insert\nAbove Inserir\nAcima +Insert\nBelow Inserir\nAbaixo +Insert\nLayout Inserir\nLayout +Insert\nLeft Inserir à\nEsquerda +Insert\nPlaceholder Inserir\nEspaço Reservado +Insert\nRight Inserir à\nDireita +Inspect Presentation Inspecionar apresentação +Inspect Shape Inspecionar forma +Inspect Slide Inspecionar slide +Interface Language Idioma da interface +Interface language: Idioma da interface: +Interface size Tamanho da interface +Isosceles Triangle Triângulo isósceles +Italiano Italiano +Italic Itálico +JPEG image (current slide) Imagem JPEG (slide atual) +Join our community on Discord Participe da nossa comunidade no Discord +Join the ArtCraft community on Discord Participe da comunidade ArtCraft no Discord +Justify Justificar +L-Shape Forma em L +Language Idioma +Last Último +Last Column Última coluna +Last Slide Último slide +Last commit Último commit +Layout Layout +Learn More Saiba mais +Learn\nMore Saiba\nMais +Ledger Paper (11x17 in) Papel Ofício (11x17 pol) +Left Esquerda +Left Brace Chave esquerda +Left Bracket Colchete esquerdo +Letter Paper (8.5x11 in) Papel Carta (8,5x11 pol) +Liberation Serif Liberation Serif +Light Blue Azul-claro +Light Green Verde-claro +Light/Dark appearance Aparência clara/escura +Lighten Clarear +Lightning Raio +Lightning Bolt Raio +Line Linha +Line Color Cor da linha +Line Spacing Espaçamento entre linhas +Line Spacing Options… Opções de espaçamento entre linhas… +Line delta Variação de linhas +Line spacing (lines): Espaçamento entre linhas (linhas): +Line with Markers Linha com marcadores +Linear Linear +Lines Linhas +Lines +/− Linhas +/− +Lines added Linhas adicionadas +Lines deleted Linhas excluídas +Link Link +Link… Link… +List Commands Listar comandos +Lock Bloquear +Long Dash Traço longo +Long Dash Dot Traço longo e ponto +Loop continuously until 'Esc' Repetir continuamente até 'Esc' +Loop until Stopped Repetir até parar +Loops Repetições +Loose Amplo +Low Baixa +MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors MIT OR Apache-2.0 · © 2026 Equipe ArtCraft e os colaboradores do DeckCraft +Margins (pt) Margens (pt) +Mark as decorative Marcar como decorativo +Mark selected text as: Marcar o texto selecionado como: +Math Matemática +Matrix Matriz +Maximize content (else ensure fit) Maximizar conteúdo (senão, ajustar) +Media Mídia +Media Info Informações de mídia +Medium Médio +Merge Cells Mesclar células +Merge Shapes Mesclar formas +Merge\nCells Mesclar\nCélulas +Merge\nShapes Mesclar\nFormas +Merged PRs PRs mesclados +Middle Meio +Minus Sign Sinal de subtração +Model Modelo +Models Modelos +Moon Lua +More Colors… # Mais cores… # +Morph Morfar +Motion Movimento +Motion Paths Trajetos de animação +Move Mover +Move Down Mover para baixo +Move Insertion Point Mover ponto de inserção +Move Section Mover seção +Move Section Down Mover seção para baixo +Move Section Up Mover seção para cima +Move Slide Mover slide +Move Up Mover para cima +Multiplication Sign Sinal de multiplicação +Multiply Multiplicar +Mute Silenciar +Name Nome +Name (A–Z) Nome (A–Z) +Name: Nome: +New Comment Novo comentário +New Presentation Nova apresentação +New Slide Novo slide +New Slide (⇧⌘N) Novo slide (⇧⌘N) +New presentation Nova apresentação +New show name: Nome da nova apresentação: +New\nComment Novo\nComentário +New\nSlide Novo\nSlide +Next Próximo +Next Slide Próximo slide +Next Window Próxima janela +Next ▶ Próximo ▶ +No Color Sem cor +No Fill Sem preenchimento +No Glow Sem brilho +No Outline Sem contorno +No Reflection Sem reflexo +No Shadow Sem sombra +No Soft Edges Sem bordas suaves +No accessibility issues found. People with disabilities should not have difficulty reading this document. Nenhum problema de acessibilidade encontrado. Pessoas com deficiência não devem ter dificuldade para ler este documento. +No comments on this slide. Nenhum comentário neste slide. +No contributor data was built into this copy. Nenhum dado de colaboradores foi incluído nesta cópia. +No fill Sem preenchimento +No line Sem linha +No model credits were built into this copy. Nenhum crédito de modelo foi incluído nesta cópia. +No notes. Sem anotações. +No sound: {err} Sem som: {err} +None Nenhum +Normal Normal +Not Equal Diferente +Not allowed Não permitido +Notes Anotações +Notes Master Mestre de anotações +Notes Master (coming soon) Mestre de anotações (em breve) +Notes Page Página de anotações +Notes Pages Páginas de anotações +Notes\nMaster Mestre de\nAnotações +Notes\nPage Página de\nAnotações +Nudge Ajuste fino +Number of columns: Número de colunas: +Number of rows: Número de linhas: +Numbering Numeração +Object Color Cor do objeto +Objects Objetos +Octagon Octógono +On Click Ao clicar +On Click of {name} Ao clicar em {name} +On Click of… (none) Ao clicar em… (nenhum) +On Mouse Click Com clique do mouse +On-screen Show (16:10) Apresentação em tela (16:10) +Open Data Abrir dados +Open Dialog Abrir diálogo +Open Recovered Presentations Abrir apresentações recuperadas +Open the sample deck Abrir a apresentação de exemplo +Open… Abrir… +Orange Laranja +Orbit Órbita +Order Objects Ordenar objetos +Origami Origami +Out Para fora +Out Bounce Saída com salto +Outer Externo +Outline Contorno +Outline (.txt) Estrutura de tópicos (.txt) +Outline View Modo de estrutura de tópicos +Outline\nView Estrutura de\nTópicos +Oval Oval +Oval ends Pontas ovais +Overhead Transparência +PDF document (.pdf) Documento PDF (.pdf) +PNG images (all slides) Imagens PNG (todos os slides) +PNG images (current slide) Imagens PNG (slide atual) +PRs PRs +Page Curl Página virada +Page Setup… Configurar página… +PageDown PageDown +PageUp PageUp +Pan Panorâmica +Pane Painel +Paragraph Parágrafo +Paragraph… Parágrafo… +Parallelogram Paralelogramo +Pareto Pareto +Partial Circle Círculo parcial +Particles Partículas +Paste Colar +Paste (⌘V) Colar (⌘V) +Paste Formatting Colar formatação +Paste and Match Formatting Colar e corresponder à formatação +Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets. Cole ou digite uma estrutura de tópicos. Linhas sem recuo viram títulos de slide; linhas com recuo de tabulação viram marcadores. +Pasted image.png Imagem colada.png +Path Trajeto +Pattern fill Preenchimento com padrão +Pause Pausar +Peek In Espiar para dentro +Peel Off Descascar +Pencil Lápis +Pentagon Pentágono +Perspective Perspectiva +Pick a graphic; type one item per line. Escolha um gráfico; digite um item por linha. +Pick a look for your slides. Ideas are generated on your device. Escolha um visual para os seus slides. As ideias são geradas no seu dispositivo. +Picture Imagem +Picture Format Formato da imagem +Picture Styles Estilos de imagem +Picture from File… Imagem do arquivo… +Picture or texture fill Preenchimento com imagem ou textura +Picture\nStyles Estilos de\nImagem +Pictures Imagens +Pie Pizza +Pin Fixar +Placeholder colour Cor do espaço reservado +Plan\nBuild\nLaunch Planejar\nConstruir\nLançar +Plaque Placa +Play Reproduzir +Play Across Slides Reproduzir em todos os slides +Play Full Screen Reproduzir em tela inteira +Play Narrations Reproduzir narrações +Play from Current Slide Reproduzir do slide atual +Play from Start Reproduzir do início +Play from Start (F5) Reproduzir do início (F5) +Play from\nCurrent Slide Reproduzir do\nSlide Atual +Play from\nStart Reproduzir do\nInício +Play/Pause Reproduzir/pausar +Playback Reprodução +Plus Mais +Plus In Mais para dentro +Plus Out Mais para fora +Plus Sign Sinal de adição +Portrait (9:16) Retrato (9:16) +Português Português +Position Posição +Position Objects Posicionar objetos +Position and Size Posição e tamanho +Post Publicação +Poster Frame Quadro de pôster +PowerPoint Presentation (.pptx) Apresentação do PowerPoint (.pptx) +Preferences Preferências +Preferences… Preferências… +Presentation Apresentação +Presentations Apresentações +Presentations and slide shows, rebuilt from scratch in pure Rust. Apresentações e exibições de slides, reconstruídas do zero em Rust puro. +Presented by a speaker (full screen) Apresentada por um orador (tela inteira) +Presenter View Modo de exibição do apresentador +Presenter View — DeckCraft Modo do apresentador — DeckCraft +Presenter\nView Modo de\nApresentador +Preset gradients Gradientes predefinidos +Prestige Prestígio +Preview Visualizar +Previous Slide Slide anterior +Print layout: Layout de impressão: +Properties Propriedades +Pulse Pulso +Punctuation Pontuação +Purple Roxo +Push Empurrar +Pyramid Pirâmide +Quick Styles Estilos rápidos +Quick\nStyles Estilos\nRápidos +Radar Radar +Radial Radial +Random Aleatório +Random Bars Barras aleatórias +Reading View Modo de leitura +Reading\nView Modo de\nLeitura +Real name Nome real +Recent Recente +Record Gravar +Recording narration needs audio capture (coming soon) Gravar narração precisa de captura de áudio (em breve) +Recovered Presentations Apresentações recuperadas +Recovered {} from the last session {} recuperadas da última sessão +Rectangle Retângulo +Rectangle: Diagonal Corners Rounded Retângulo: cantos diagonais arredondados +Rectangle: Diagonal Corners Snipped Retângulo: cantos diagonais cortados +Rectangle: Rounded Corners Retângulo: cantos arredondados +Rectangle: Single Corner Rounded Retângulo: um canto arredondado +Rectangle: Single Corner Snipped Retângulo: um canto cortado +Rectangle: Top Corners One Rounded and One Snipped Retângulo: cantos superiores, um arredondado e um cortado +Rectangle: Top Corners Rounded Retângulo: cantos superiores arredondados +Rectangle: Top Corners Snipped Retângulo: cantos superiores cortados +Rectangles Retângulos +Rectangular Retangular +Red Vermelho +Red Pen Caneta vermelha +Redo Refazer +Redo (⌘Y) Refazer (⌘Y) +Reflected Rounded Rectangle Retângulo arredondado refletido +Reflection Reflexo +Regroup Reagrupar +Regular Pentagon Pentágono regular +Rehearse Timings Ensaiar intervalos +Rehearse\nTimings Ensaiar\nIntervalos +Remove Remover +Remove All Animations Remover todas as animações +Remove All Sections Remover todas as seções +Remove Animation Remover animação +Remove Background Remover plano de fundo +Remove Background (coming soon) Remover plano de fundo (em breve) +Remove Section Remover seção +Remove Section & Slides Remover seção e slides +Remove stop Remover parada +Remove\nBackground Remover\nPlano de Fundo +Rename Renomear +Rename Layout Renomear layout +Rename Section Renomear seção +Rename Section… Renomear seção… +Rename Slide Renomear slide +Rename… Renomear… +Render Slide Renderizar slide +Reopen Reabrir +Reorder Animation Reordenar animação +Reorder Objects Reordenar objetos +Replace straight quotes with smart quotes Substituir aspas retas por aspas tipográficas +Reply Responder +Reply… Responder… +Reset Redefinir +Reset Background Redefinir plano de fundo +Reset Crop Redefinir recorte +Reset Effects Redefinir efeitos +Reset Picture Redefinir imagem +Reset Slide Redefinir slide +Reset Slide Background Redefinir plano de fundo do slide +Resize shape to fit text Redimensionar forma para ajustar ao texto +Resolve Resolver +Resolve Thread Resolver conversa +Return Return +Reveal Revelar +Reverse the order Inverter a ordem +Revert Reverter +Review Revisão +Rewind after Playing Rebobinar após reproduzir +Ribbon Tab Guia da faixa de opções +Ribbon: Tilted Down Faixa: inclinada para baixo +Ribbon: Tilted Up Faixa: inclinada para cima +Right Direita +Right Brace Chave direita +Right Bracket Colchete direito +Right Triangle Triângulo retângulo +Right-to-Left Text Direction Direção do texto da direita para a esquerda +Ring Anel +Ripple Ondulação +Rise Up Subir +Rotate Girar +Rotate Left 90° Girar 90° à esquerda +Rotate Right 90° Girar 90° à direita +Rotate all text 270° Girar todo o texto 270° +Rotate all text 90° Girar todo o texto 90° +Rotate with shape Girar com a forma +Rotation Rotação +Round Dot Ponto redondo +Rounded Diagonal Corner Canto diagonal arredondado +Ruler Régua +Saturation Saturação +Saturation {}% Saturação {}% +Save Salvar +Save (⌘S) Salvar (⌘S) +Save As… Salvar como… +Save AutoRecover Information Salvar informações de AutoRecuperação +Save as Template… Salvar como modelo… +Save to Data Salvar em dados +Saved Salvo +Saved {path} Salvo {path} +Scatter Dispersão +ScreenTip: Dica de tela: +Screenshot Captura de tela +Screenshot (not available yet) Captura de tela (ainda não disponível) +Scroll: Horizontal Rolar: horizontal +Scroll: Vertical Rolar: vertical +Search commands (⇧⌘P) Pesquisar comandos (⇧⌘P) +Search commands… Pesquisar comandos… +Section Seção +Seek Buscar +Select Selecionar +Select All Selecionar tudo +Select All Text Selecionar todo o texto +Select Cells Selecionar células +Select Objects Selecionar objetos +Select Paragraph Selecionar parágrafo +Select Slides Selecionar slides +Select Text Selecionar texto +Select Word Selecionar palavra +Select a chart first. Selecione um gráfico primeiro. +Select a shape to format it. Selecione uma forma para formatá-la. +Select an object on the slide and add an animation from the Animations tab. Selecione um objeto no slide e adicione uma animação na guia Animações. +Selection Seleção +Selection Pane Painel de seleção +Selection Pane… Painel de seleção… +Selection\nPane Painel de\nSeleção +Send Backward Enviar para trás +Send to Back Enviar para o fundo +Send\nBackward Enviar para\nTrás +Sentence case. Maiúscula na primeira letra. +Set Text Definir texto +Set Up Show… Configurar apresentação… +Set Up Slide Show Configurar apresentação de slides +Set Up Slide Show… Configurar apresentação de slides… +Set Up\nSlide Show Configurar\nApresentação +Set as Default Shape Definir como forma padrão +Shading Sombreamento +Shadow Sombra +Shape Forma +Shape Effects Efeitos de forma +Shape Fill Preenchimento da forma +Shape Format Formato da forma +Shape Gallery Galeria de formas +Shape Outline Contorno da forma +Shape Styles Estilos de forma +Shape {} Forma {} +Shape\nStyles Estilos de\nForma +Shapes Formas +Shift+ Shift+ +Shift+F9 Shift+F9 +Show Mostrar +Show All Mostrar tudo +Show Comments Mostrar comentários +Show Media Controls Mostrar controles de mídia +Show Slide Mostrar slide +Show smart guides Mostrar guias inteligentes +Show type Tipo de exibição +Show without animation Exibir sem animação +Show without narration Exibir sem narração +Show/Hide Mostrar/ocultar +Show\nComments Mostrar\nComentários +Shred Triturar +Shrink & Turn Reduzir e girar +Shrink text on overflow Reduzir texto em caso de excesso +Simple Frame, White Moldura simples, branca +Size Tamanho +Size & Properties Tamanho e propriedades +Slide Master Slide Mestre +Slide Number Número do slide +Slide Show Apresentação de slides +Slide Size Tamanho do slide +Slide Sorter Classificador de slides +Slide number Número do slide +Slide timings recorded. Intervalos dos slides registrados. +Slide {:02}:{:04.1} Total {:02}:{:02} Slide {:02}:{:04.1} Total {:02}:{:02} +Slide {} of {} Slide {} de {} +Slide {}: {}{} Slide {}: {}{} +Slide {}: “{w}” Slide {}: “{w}” +Slide\nMaster Slide\nMestre +Slide\nNumber Número do\nSlide +Slide\nSize Tamanho do\nSlide +Slide\nSorter Classificador\nde Slides +Slides (e.g. 1,3,5): Slides (ex.: 1,3,5): +Slides from Outline Slides da estrutura de tópicos +Slides from Outline… Slides da estrutura de tópicos… +Slides sized for: Slides dimensionados para: +SmartArt SmartArt +Smile Sorriso +Smiley Face Carinha sorridente +Smoothly Suavemente +Smoothly Left Suavemente à esquerda +Smoothly Right Suavemente à direita +Snap objects to grid Alinhar objetos à grade +Soft Edge Rectangle Retângulo com bordas suaves +Soft Edges Bordas suaves +Solid Sólido +Solid fill Preenchimento sólido +Solid line Linha sólida +Sort Classificar +Source on GitHub Código-fonte no GitHub +Space Espaço +Spacing after (pt): Espaçamento depois (pt): +Spacing before (pt): Espaçamento antes (pt): +Special — hanging (in): Especial — deslocado (pol): +Speech Fala +Speech Bubble: Oval Balão de fala: oval +Speech Bubble: Rectangle Balão de fala: retângulo +Speech Bubble: Rectangle with Corners Rounded Balão de fala: retângulo com cantos arredondados +Spelling Ortografia +Spin Girar +Spinner Catraca +Split Dividir +Split Cells Dividir células +Split\nCells Dividir\nCélulas +Square Quadrado +Square (1:1) Quadrado (1:1) +Square Dot Ponto quadrado +Stacked Empilhado +Stacked Area Área empilhada +Stacked Bar Barra empilhada +Stacked Column Coluna empilhada +Stacked Line Linha empilhada +Standard (4:3) Padrão (4:3) +Standard Colors Cores padrão +Star Estrela +Star: 10 Points Estrela: 10 pontas +Star: 12 Points Estrela: 12 pontas +Star: 16 Points Estrela: 16 pontas +Star: 24 Points Estrela: 24 pontas +Star: 32 Points Estrela: 32 pontas +Star: 4 Points Estrela: 4 pontas +Star: 5 Points Estrela: 5 pontas +Star: 6 Points Estrela: 6 pontas +Star: 7 Points Estrela: 7 pontas +Star: 8 Points Estrela: 8 pontas +Stars and Banners Estrelas e faixas +Start After Previous Iniciar após o anterior +Start Automatically Iniciar automaticamente +Start On Click Iniciar ao clicar +Start With Previous Iniciar com o anterior +Start a conversation Iniciar uma conversa +Start time (s): Hora de início (s): +Start: Início: +Stealth end Ponta furtiva +Stock Imagem de banco +Stop Parar +Stop Editing Text Parar de editar texto +Strikethrough Tachado +Strips Tiras +Style {i} Estilo {i} +Subscript Subscrito +Subtle Sutil +Sun Sol +Sunburst Explosão solar +Superscript Sobrescrito +Surface Superfície +Switch Alternar +Swivel Girar em torno do eixo +Symbol Símbolo +System language Idioma do sistema +Tab Tab +Table Tabela +Table Design Design da tabela +Table Style Options Opções de estilo da tabela +Table Styles Estilos de tabela +Table… Tabela… +Task Pane Painel de tarefas +Teardrop Gota +Teeter Balançar +Text Texto +Text Box Caixa de texto +Text Box Margins Margens da caixa de texto +Text Direction Direção do texto +Text Effects Efeitos de texto +Text Fill Preenchimento do texto +Text Highlight Color Cor de realce do texto +Text Options Opções de texto +Text Outline Contorno do texto +Text Shadow Sombra do texto +Text/Background - Dark 1 Texto/Plano de fundo - Escuro 1 +Text/Background - Dark 2 Texto/Plano de fundo - Escuro 2 +Text/Background - Light 1 Texto/Plano de fundo - Claro 1 +Text/Background - Light 2 Texto/Plano de fundo - Claro 2 +Text\nBox Caixa de\nTexto +The spelling check is complete. No issues found. A verificação ortográfica foi concluída. Nenhum problema encontrado. +Theme Colors Cores do tema +Theme Gallery Galeria de temas +Themes Temas +Thesaurus Dicionário de sinônimos +Thesaurus (coming soon) Dicionário de sinônimos (em breve) +This Document Este documento +Thought Pensamento +Thought Bubble: Cloud Balão de pensamento: nuvem +Through Black Através do preto +Tight Estreito +Timing Intervalo +Top Em cima +Total Row Linha de total +Transition Transição +Transition Gallery Galeria de transições +Transitions Transições +Translate Traduzir +Translation needs a language model (not bundled) A tradução precisa de um modelo de idioma (não incluído) +Transparency Transparência +Trapezoid Trapézio +Treemap Mapa de árvore +Triangle Triângulo +Trigger Gatilho +Trim Aparar +Trim Media Aparar mídia +Turns Voltas +Type Tipo +Type Text Digitar texto +Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). Digite uma equação na forma linear (ex.: a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). +Uncover Revelar +Underline Sublinhado +Undo Desfazer +Undo (⌘Z) Desfazer (⌘Z) +Ungroup Desagrupar +Up Para cima +Use Timings Usar intervalos +Use timings, if present Usar intervalos, se houver +Username Nome de usuário +Version Versão +Version {} Versão {} +Vert In Vert. para dentro +Vert Out Vert. para fora +Vertical Vertical +Vertical alignment Alinhamento vertical +Very Loose Muito amplo +Very Tight Muito estreito +Video Vídeo +Video from File… Vídeo do arquivo… +View Exibir +Volume Volume +Vortex Vórtice +Waterfall Cascata +Wave Onda +Web Page or File Página da Web ou arquivo +Wedge Cunha +Weight Espessura +Wheel Roda +Widescreen (13.333x7.5 in) Widescreen (13,333x7,5 pol) +Widescreen (16:9) Widescreen (16:9) +Width Largura +Width (in): Largura (pol): +Width: Largura: +Width: Largura: +Wind Vento +Window Janela +Wipe Limpar +With Previous Com o anterior +WordArt WordArt +WordArt style {} Estilo de WordArt {} +Words Palavras +Wrap Text in Shape Dispor texto na forma +Wrap text in shape Dispor texto na forma +Yellow Amarelo +Your changes will be lost if you don't save them. Suas alterações serão perdidas se você não salvá-las. +Your name (comments): Seu nome (comentários): +Zoom Zoom +Zoom In Ampliar +Zoom Out Reduzir +Zoom… Zoom… +a) b) c) a) b) c) +a. b. c. a. b. c. +dropped file arquivo arrastado +i. ii. iii. i. ii. iii. +no presentation open nenhuma apresentação aberta +no slides nenhum slide +no text insertion point nenhum ponto de inserção de texto +nothing selected nada selecionado +nothing to redo nada para refazer +nothing to undo nada para desfazer +select text or a shape selecione texto ou uma forma +select two or more objects selecione dois ou mais objetos +size (pt) tamanho (pt) +tOGGLE cASE aLTERNAR mAIÚSCULAS/minúsculas +unknown interface language idioma da interface desconhecido +{ch} Bullet {ch} Marcador +{f} (Body) {f} (Corpo) +{f} (Headings) {f} (Títulos) +{name}: DeckCraft can't open this kind of file {name}: o DeckCraft não pode abrir este tipo de arquivo +{n} Column{} {n} Coluna{} +{r} pt {r} pt +{r} pt glow {r} pt glow +{w} pt {w} pt +{} ({} slides) {} ({} slides) +{} Slide Master {} Slide Mestre +{} contributors · {} commits {} colaboradores · {} commits +{} presentations {} apresentações +{}x{} Table {}x{} Tabela +“{}” is not available yet. “{}” ainda não está disponível. +−Bin −bin. +▶ Play All ▶ Reproduzir tudo +◀ Previous ◀ Anterior +✏ on ✏ ativado 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..c5e6b3c18 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|pt-BR}"), ("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,17 @@ 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, + "pt-BR" => i18n::Language::PtBr, + _ => 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 +438,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 +536,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 +550,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 +595,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 +703,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..1ca42adc0 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,17 @@ 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" }, "стовпець|стовпці|стовпців", "coluna|colunas")) + .clicked() + { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } @@ -605,7 +618,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 +626,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 +643,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 +652,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 +663,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 +737,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 +787,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 +805,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 +820,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 +829,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 +838,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 +888,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 +917,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 +932,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 +942,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 +1021,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 +1030,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 +1045,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 +1056,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 +1071,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 +1095,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 +1156,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 +1199,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 +1280,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 +1289,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 +1297,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 +1318,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 +1358,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 +1380,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 +1389,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 +1397,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 +1416,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 +1513,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 +1578,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 +1598,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 +1634,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 +1662,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 +1678,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 +1702,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 +1743,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 +1764,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 +1790,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 +1832,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 +1896,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 +1928,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 +1964,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 +2036,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 +2059,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 +2077,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 +2101,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 +2128,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 +2153,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 +2210,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 +2299,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 +2314,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 +2342,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 +2373,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 +2393,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..ef99188f3 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-12 · **Change:** minor (localization gap narrowed further after the Brazilian Portuguese 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 and Brazilian Portuguese 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, Ukrainian and Brazilian Portuguese 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,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Narrowed localization gap further: Brazilian Portuguese catalog added; other target languages, IME and RTL UI remain | +| 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..1b6ca7c90 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,18 +1,22 @@ # 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Brazilian Portuguese UI catalog added alongside Ukrainian) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -DeckCraft's interface is English only today: strings are literals in the UI and engine code, with -no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 -(Ukrainian, open). Dimension score: **~8% ready** (estimated: English complete, slide-text script +DeckCraft's interface supports English, Ukrainian and Brazilian Portuguese through +`crates/ui-egui/src/i18n/uk.tsv` and `crates/ui-egui/src/i18n/pt-br.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 +29,8 @@ 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 | — | +| Portuguese (Brazil) | pt-BR | UI catalog; coverage checked by tests | UI captions; diagnostics retain original wording | Latin; bundled UI fonts tested | no | available; English fallback | yes (pt-BR, pt-PT) | — | | 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 +43,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: **2 (Ukrainian and Brazilian Portuguese)**. 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 +61,6 @@ common misspellings (`crates/engine/src/cmd/review.rs`); see [gaps.md](gaps.md#p | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Added the Brazilian Portuguese catalog (`pt-br.tsv`), language selection and bundled-font coverage | +| 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..4c8114f70 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-12 · **Change:** minor (Brazilian Portuguese UI added on the localization infrastructure) · **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,8 @@ 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); + Brazilian Portuguese rides on the same catalog infrastructure. ## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents) @@ -43,7 +44,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 +110,7 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Recorded the Brazilian Portuguese catalog on the shared localization infrastructure | +| 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..87a89e2da 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-12 · **Change:** minor (localization evidence updated for the Brazilian Portuguese 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, Ukrainian and Brazilian Portuguese UI catalogs with system-language selection. PowerPoint for Mac ships 26 languages; Ukrainian and Brazilian Portuguese are 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,8 @@ scored done (16 open issues). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Updated localization evidence for the Brazilian Portuguese UI; overall readiness estimates stay unchanged | +| 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 |