diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ff680bda2..c07c88cd3 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -31,6 +31,8 @@ artwork. The ribbon and menu icons stay original drawings. | `crates/media/tests/fixtures/damaged/*` (`ebml-header.mkv`, `ebml-position.webm`, `demux-loop.mp4`, `demux-loop.m4a`) | DeckCraft contributors | the `clip-*` and `tone*` fixtures above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged media must not panic or hang | | `crates/media/tests/fixtures/damaged/*` (`stsc-count.m4a`, `stsz-size.m4a`) | DeckCraft contributors | `tone.m4a` above with bytes changed by a mutation fuzzer | CC0-1.0 | Damaged MP4 sample tables must not abort | | `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/pl.tsv` | @brzuchal, assisted by Claude | Contributor-original Polish translation written from the English interface labels in this repository; no other product localisation copied | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Polish interface text | | `crates/render/tests/fixtures/colr-squares.ttf` | DeckCraft contributors | two squares as a COLRv0/CPAL test font, built by `make_colr_font.py` (fontTools as the builder only) | CC0-1.0 | Colour glyph rendering tests | | `crates/fonts/tests/fixtures/legacy-semibold.ttf` | DeckCraft contributors | a one-glyph Semibold face named like Segoe UI Semibold (its Windows family differs from the typographic one), built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | | `crates/fonts/tests/fixtures/legacy-regular.ttf` | DeckCraft contributors | the same family's one-glyph Regular face, built by `make_legacy_font.py` (fontTools as the builder only) | CC0-1.0 | Font lookup by Windows family name tests | diff --git a/Cargo.lock b/Cargo.lock index 1995bcda7..8b53c8a3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,6 +942,7 @@ dependencies = [ "log", "rfd", "serde_json", + "sys-locale", "winit", "winresource", ] @@ -1593,7 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2847,7 +2848,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3320,7 +3321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3863,7 +3864,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3920,7 +3921,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4524,6 +4525,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 = "tar" version = "0.4.46" @@ -4544,7 +4554,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4810,7 +4820,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5461,7 +5471,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/ROADMAP.md b/ROADMAP.md index b52abe280..76174456c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (Ukrainian and Polish UI catalogs and language selection; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -90,7 +90,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 Polish: Preferences offers System language, English, Українська and Polski. +Ukrainian and Polish OS/browser locales select those languages 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 | @@ -108,8 +112,8 @@ English only today; no string catalog yet (Ukrainian is in review as PR #34). De | Korean | ko | 0% | none | 4–7 | | Vietnamese | vi | 0% | none | 3–5 | -Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac -ships 26. +Other languages shipped: **Ukrainian (uk)**, with English fallback and bundled Cyrillic glyph +coverage, and **Polish (pl)**. PowerPoint for Mac ships 26 languages; additional catalogs and native review remain. ## Upcoming @@ -282,6 +286,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) | | 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line | | 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) | +| 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 | @@ -379,6 +384,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | | 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) | | 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% | +| 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 f8e68a35c..fec6d693b 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -41,6 +41,7 @@ pub fn start() { deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); 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) { @@ -123,10 +124,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 b336a8433..4561bbcba 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 aa7e26112..2fafbe038 100644 --- a/apps/deckcraft/src/main.rs +++ b/apps/deckcraft/src/main.rs @@ -103,21 +103,24 @@ 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"]), - "theme" => d.add_filter("Themes and presentations", &["thmx", "pptx", "potx", "ppsx"]).add_filter("Office Theme", &["thmx"]), + "video" => d.add_filter(deckcraft_ui_egui::i18n::tr("Video"), &["mp4", "m4v", "mov", "webm", "mkv", "wmv"]), + "theme" => d + .add_filter(deckcraft_ui_egui::i18n::tr("Themes and presentations"), &["thmx", "pptx", "potx", "ppsx"]) + .add_filter(deckcraft_ui_egui::i18n::tr("Office Theme"), &["thmx"]), _ => 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()) })), @@ -275,7 +278,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 f4435899b..3c37da221 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -215,6 +215,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. @@ -250,6 +252,7 @@ impl Default for RenderOpts<'_> { RenderOpts { scale: 1.0, edit: false, + placeholder_prompt: None, show_hidden: false, state: None, skip: &[], @@ -1421,7 +1424,7 @@ impl Renderer { } } 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)); @@ -2088,7 +2091,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()); @@ -2098,7 +2101,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 29e1fe42a..b9f8c921f 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -964,6 +964,28 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() { 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"); +} + #[test] fn a_linear_gradient_ramps_like_powerpoint() { // Black to white, left to right: PowerPoint draws 105 a quarter of the way and 186 half way diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs index ee760767d..fc9979ac2 100644 --- a/crates/render/src/transition.rs +++ b/crates/render/src/transition.rs @@ -107,6 +107,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> { skip_text: o.skip_text, editing_text: o.editing_text, gif_times: o.gif_times, + placeholder_prompt: o.placeholder_prompt, threads: o.threads, offset: o.offset, size: o.size, diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0ae83b696..bc1046504 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -161,7 +161,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { }); 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"})); } @@ -1387,7 +1387,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } 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(); } @@ -1396,59 +1396,59 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { // Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items. let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes)); if editing { - if ui.button("Paragraph…").clicked() { + if ui.button(crate::i18n::tr("Paragraph…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); ui.close(); } for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { - 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(); } } - if ui.button("Link…").clicked() { + if ui.button(crate::i18n::tr("Link…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); } ui.separator(); - 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 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(); } if crate::ribbon::enabled(app, "shape.editPoints") && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) - && ui.button("Edit Points").clicked() + && ui.button(crate::i18n::tr("Edit Points")).clicked() { let _ = app.run("shape.editPoints", json!({"on": true})); ui.close(); } - if ui.button("Font…").clicked() { + if ui.button(crate::i18n::tr("Font…")).clicked() { let _ = app.run("format.fontDialog", json!({})); ui.close(); } - if ui.button("Paragraph…").clicked() { + if ui.button(crate::i18n::tr("Paragraph…")).clicked() { let _ = app.run("format.paragraphDialog", 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!({})); }); let locked = app.session.active().is_some_and(|d| d.selected_shapes().iter().all(|s| s.locked)); - if ui.button(if locked { "Unlock" } else { "Lock" }).clicked() { + if ui.button(if locked { crate::i18n::tr("Unlock") } else { crate::i18n::tr("Lock") }).clicked() { let _ = app.run("shape.lock", json!({"locked": !locked})); ui.close(); } @@ -1456,32 +1456,32 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { .session .active() .is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. }))); - if picture && ui.button("Change Picture…").clicked() { + if picture && ui.button(crate::i18n::tr("Change Picture…")).clicked() { let _ = app.run("app.changePictureDialog", json!({})); ui.close(); } - 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("Size and Position…").clicked() { + if ui.button(crate::i18n::tr("Size and Position…")).clicked() { let _ = app.run("shape.sizePosition", 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() @@ -1494,28 +1494,28 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); - ui.menu_button("Grid and Guides", |ui| { + ui.menu_button(crate::i18n::tr("Grid and Guides"), |ui| { let mut on = app.ui.guides; - if ui.checkbox(&mut on, "Guides").clicked() { + if ui.checkbox(&mut on, crate::i18n::tr("Guides")).clicked() { let _ = app.run("view.guides", json!({"on": on})); } for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run("guide.add", json!({"vertical": vertical})); let _ = app.run("view.guides", json!({"on": true})); ui.close(); } } }); - 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 0cef384bf..7db766ffc 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,40 +217,44 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label("Show"); + ui.label(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(); - let field = ui.label("Sort"); - let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + let field = ui.label(crate::i18n::tr("Sort")); + let combo = 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(); } } }); let _ = combo.response.labelled_by(field.id); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(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 { @@ -269,7 +275,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 { @@ -298,14 +308,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 4de8ffe19..818b5b0f3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -83,7 +83,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; let mut open = true; let mut close = false; - let mut window = egui::Window::new(title(&d.id)) + let mut window = egui::Window::new(crate::i18n::tr(title(&d.id))) .id(egui::Id::unique(("dialog", d.id.clone()))) .collapsible(false) .resizable(false) @@ -147,12 +147,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; } }); @@ -164,6 +166,7 @@ fn preferences_snapshot(app: &SlideApp) -> Value { json!({ "dark": app.ui.brightness == theme::Brightness::Dark, "uiScale": app.ui.ui_scale, + "language": app.ui.interface_language.code(), "prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null), }) } @@ -179,6 +182,11 @@ fn restore_preferences(app: &mut SlideApp, d: &Dialog) { if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) { app.ui.ui_scale = scale as f32; } + if let Some(code) = before.get("language").and_then(Value::as_str) + && code != app.ui.interface_language.code() + { + let _ = app.run("app.language", json!({ "language": code })); + } if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) { app.session.prefs = prefs; } @@ -189,7 +197,7 @@ fn restore_preferences(app: &mut SlideApp, d: &Dialog) { fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) { let mut v = d.get(key, default); ui.horizontal(|ui| { - let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(label)); + let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(crate::i18n::tr(label))); ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)).labelled_by(l.id); }); d.fields.insert(key.into(), v); @@ -209,7 +217,7 @@ fn alt_text_starts_decorative(decorative: impl IntoIterator) -> boo 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 } @@ -994,8 +1002,8 @@ fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } let mut mode = d.get("mode", "percent"); let mut percent = d.get("percent", "100"); - ui.label("Zoom to"); - if ui.radio(mode == "fit", "Fit").clicked() { + ui.label(crate::i18n::tr("Zoom to")); + if ui.radio(mode == "fit", crate::i18n::tr("Fit")).clicked() { mode = "fit".into(); } for pct in ZOOM_PRESETS { @@ -1006,7 +1014,7 @@ fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } ui.horizontal(|ui| { - let l = ui.label("Percent:"); + let l = ui.label(crate::i18n::tr("Percent:")); let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id); if edit.changed() { mode = "percent".into(); @@ -1024,7 +1032,7 @@ fn zoom_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { match parse_zoom_percent(&percent) { Some(scale) => app.ui.zoom = Some(scale), None => { - app.set_status("Enter a zoom percent"); + app.set_status(crate::i18n::tr("Enter a zoom percent")); return false; } } @@ -1110,17 +1118,19 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { put_slide_dim(d, "w", cur.width, ppu); put_slide_dim(d, "h", cur.height, ppu); } - let label = ui.label("Slides sized for:"); + let label = ui.label(crate::i18n::tr("Slides sized for:")); let preset = d.get("preset", ""); - let combo = 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()); - put_slide_dim(d, "w", *w, ppu); - put_slide_dim(d, "h", *h, ppu); + let combo = 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()); + put_slide_dim(d, "w", *w, ppu); + put_slide_dim(d, "h", *h, ppu); + } } - } - }); + }); let _ = combo.response.labelled_by(label.id); let abbr = unit_abbr(len); let (w_seed, h_seed) = (field_of(d, "w"), field_of(d, "h")); @@ -1177,9 +1187,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()); } }); @@ -1208,13 +1218,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let slide_default = seed.slide.to_string(); let mode = d.get("mode", seed.mode); 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()); } }); @@ -1254,13 +1264,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()); } } @@ -1298,21 +1308,36 @@ 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", + ["презентації", "презентацій", "презентацій"], + ["prezentacji", "prezentacjach", "prezentacjach"], + ) + }; + 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; @@ -1320,7 +1345,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; } }); @@ -1361,7 +1386,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "zoom" => zoom_dialog(app, ui, d), "comment" => { let mut v = d.get("text", ""); - ui.add(egui::TextEdit::multiline(&mut v).hint_text("Comment").desired_rows(4).desired_width(320.0)); + ui.add(egui::TextEdit::multiline(&mut v).hint_text(crate::i18n::tr("Comment")).desired_rows(4).desired_width(320.0)); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Post"); if ok && !v.trim().is_empty() { @@ -1606,7 +1631,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { (cur, deco) }) .unwrap_or_default(); - ui.label("How would you describe this object and its context to someone who is blind or has low vision?"); + ui.label(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).hint_text("Alt text").desired_rows(4).desired_width(340.0)); d.fields.insert("text".into(), v.clone()); @@ -1618,7 +1643,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).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); d.fields.insert("text".into(), v.clone()); @@ -1708,7 +1733,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "paragraph" => paragraph_dialog(app, ui, d), "tabs" => tabs_dialog(app, ui, d), "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"), @@ -1720,7 +1745,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("Animated GIF (.gif)", "gif"), ("Outline/RTF (.rtf)", "rtf"), ] { - if ui.radio(fmt == f, l).clicked() { + if ui.radio(fmt == f, crate::i18n::tr(l)).clicked() { d.fields.insert("fmt".into(), f.into()); } } @@ -1728,7 +1753,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if fmt == "pdf" { ui.add_space(4.0); ui.horizontal(|ui| { - let field = ui.label("Print layout:"); + let field = ui.label(crate::i18n::tr("Print layout:")); let combo = egui::ComboBox::from_id_salt("pdf_layout") .selected_text(match pdf_layout.as_str() { "notes" => "Notes Pages", @@ -1751,7 +1776,7 @@ 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()); } } @@ -1759,7 +1784,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = combo.response.labelled_by(field.id); }); let mut hidden = d.get("pdfHidden", "false") == "true"; - if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + if ui.checkbox(&mut hidden, crate::i18n::tr("Include hidden slides")).changed() { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } @@ -1855,7 +1880,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { 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", "$€£¥¢₹₩₽₺₿"), @@ -1865,7 +1890,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() { @@ -1881,7 +1906,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).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); d.fields.insert("eq".into(), v.clone()); @@ -1949,7 +1974,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ), }; if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); + ui.label(crate::i18n::tr("The spelling check is complete. No issues found.")); } let mut add_ignore = None; egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { @@ -1958,15 +1983,15 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let slide = item.get("slide").cloned().unwrap_or_default(); let shape = item.get("shape").cloned().unwrap_or_default(); ui.horizontal(|ui| { - ui.label(format!("Slide {}: “{w}”", slide.as_u64().unwrap_or(0) + 1)); - if ui.small_button("Go to").clicked() { + ui.label(crate::i18n::format("Slide {}: “{w}”", &[(slide.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": slide})); let _ = app.run("edit.select", json!({"ids": [shape]})); } - if ui.small_button("Ignore All").clicked() { + if ui.small_button(crate::i18n::tr("Ignore All")).clicked() { add_ignore = Some(w.to_string()); } - if ui.small_button("Add to Dictionary").clicked() + if ui.small_button(crate::i18n::tr("Add to Dictionary")).clicked() && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) { app.set_status(e.to_string()); @@ -1981,8 +2006,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { { ui.horizontal(|ui| { ui.add_space(16.0); - ui.label(egui::RichText::new("Repeated word").weak()); - if ui.small_button("Delete Repeated Word").clicked() { + ui.label(egui::RichText::new(crate::i18n::tr("Repeated word")).weak()); + if ui.small_button(crate::i18n::tr("Delete Repeated Word")).clicked() { let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -2069,18 +2094,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(), + issue_text(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()).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]})); @@ -2101,17 +2130,17 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let has_sel = deckcraft_engine::cmd::has_text_or_shapes(&app.session).is_ok(); let mut all = !has_sel || d.get("scope", "") == "all"; ui.horizontal(|ui| { - ui.label("Apply to:"); - if ui.add_enabled(has_sel, egui::RadioButton::new(!all, "Selected text")).clicked() { + ui.label(crate::i18n::tr("Apply to:")); + if ui.add_enabled(has_sel, egui::RadioButton::new(!all, crate::i18n::tr("Selected text"))).clicked() { d.fields.insert("scope".into(), "sel".into()); all = false; } - if ui.radio(all, "Whole presentation").clicked() { + if ui.radio(all, crate::i18n::tr("Whole presentation")).clicked() { d.fields.insert("scope".into(), "all".into()); all = true; } }); - ui.label(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" }); + ui.label(crate::i18n::tr(if all { "Mark all text in the presentation as:" } else { "Mark selected text as:" })); egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { // The selection's own language first when the list doesn't have it. let own = (!crate::squiggles::LANGS.iter().any(|(_, t)| t.eq_ignore_ascii_case(&cur))).then(|| (cur.clone(), cur.clone())); @@ -2121,15 +2150,16 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { // the tag is the explicit fallback (egui 0.37 aborts on a missing glyph). let name = crate::squiggles::display_lang_name(ui.ctx(), &tag); let installed = deckcraft_engine::spell::dictionary(&tag).is_some(); - let label = if installed { format!("{name} (dictionary)") } else { name }; + let name = crate::i18n::tr(&name).to_string(); + let label = if installed { format!("{name} ({})", crate::i18n::tr("dictionary")) } else { name }; if ui.selectable_label(lang.eq_ignore_ascii_case(&tag), label).clicked() { d.fields.insert("lang".into(), tag); } } }); ui.add_space(4.0); - ui.label(egui::RichText::new("Spelling is checked for languages marked (dictionary).").small().weak()); - if ui.checkbox(&mut no_proof, "Do not check spelling or grammar").changed() { + ui.label(egui::RichText::new(crate::i18n::tr("Spelling is checked for languages marked (dictionary).")).small().weak()); + if ui.checkbox(&mut no_proof, crate::i18n::tr("Do not check spelling or grammar")).changed() { d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); } let (ok, cancel) = buttons(ui, "OK"); @@ -2144,12 +2174,21 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut started = false; for s in &shows { ui.horizontal(|ui| { - ui.label(format!("{} ({} slides)", s.name, s.slides.len())); - if ui.small_button("Show").clicked() { + ui.label(format!( + "{} ({})", + s.name, + crate::i18n::count( + s.slides.len(), + if s.slides.len() == 1 { "slide" } else { "slides" }, + ["слайд", "слайди", "слайдів"], + ["slajd", "slajdy", "slajdów"] + ) + )); + if ui.small_button(crate::i18n::tr("Show")).clicked() { run(app, "show.start", json!({"custom": s.name})); started = true; } - if ui.small_button("Delete").clicked() { + if ui.small_button(crate::i18n::tr("Delete")).clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); @@ -2174,11 +2213,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() { @@ -2199,11 +2238,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(Vec::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()); } @@ -2241,7 +2280,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 [ @@ -2252,7 +2291,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()); } } @@ -2271,7 +2310,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()); } } @@ -2338,9 +2377,20 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if d.params.get("before").is_none() { d.params = json!({ "before": preferences_snapshot(app) }); } - ui.label(egui::RichText::new("General").font(theme::bold(13.0))); + ui.horizontal(|ui| { + let field = ui.label(crate::i18n::tr("Interface language:")); + let combo = 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()})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); + 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})); } // While the pointer holds the slider its value only waits in the dialog: applying it @@ -2351,7 +2401,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75)); // No visible label here (the row is laid out to fit); the accessible name only. let enabled = r.enabled(); - r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size")); + r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), crate::i18n::tr("Interface size"))); if r.is_pointer_button_down_on() { if r.changed() || pending.is_some() { d.fields.insert("uiScale".into(), scale.to_string()); @@ -2364,18 +2414,18 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here. ui.horizontal(|ui| { - ui.label("Measurement units:"); + ui.label(crate::i18n::tr("Measurement units:")); let units = &mut app.session.prefs.units; - ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); - ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); + ui.radio_value(units, deckcraft_geom::Unit::Inches, crate::i18n::tr("Inches")); + ui.radio_value(units, deckcraft_geom::Unit::Centimeters, crate::i18n::tr("Centimeters")); }); - ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); - ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); - ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); - ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); + ui.label(egui::RichText::new(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.horizontal(|ui| { - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); - if ui.button("AutoCorrect Options…").clicked() { + ui.checkbox(&mut app.session.prefs.autocorrect, crate::i18n::tr("AutoCorrect as you type")); + if ui.button(crate::i18n::tr("AutoCorrect Options…")).clicked() { *d = Dialog::new("autocorrect"); } }); @@ -2383,7 +2433,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { return false; } ui.horizontal(|ui| { - let label = ui.label("Your name (comments):"); + let label = ui.label(crate::i18n::tr("Your name (comments):")); // Name the field for assistive technology; a bare text edit next to a label is an // unnamed input in egui 0.37's accessibility tree. let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); @@ -2399,7 +2449,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "themeFonts" => theme_fonts_dialog(app, ui, d), "dateTime" => date_time_dialog(app, ui, d), _ => { - ui.label(format!("“{}” is not available yet.", d.id)); + ui.label(crate::i18n::format("“{}” is not available yet.", std::slice::from_ref(&d.id))); let (ok, cancel) = buttons(ui, "OK"); ok || cancel } @@ -2869,17 +2919,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), + ); }); } @@ -2887,7 +2941,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) { @@ -3538,6 +3592,19 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { close } +/// An accessibility checker finding in the interface language. The engine reports them in +/// English (`review.accessibility` is a command, so its output stays stable); the known ones are +/// translated here, the contrast one with its ratio kept, and any other is shown as it came. +fn issue_text(issue: &str) -> String { + if let Some(ratio) = issue.strip_prefix("Hard-to-read text contrast (").and_then(|r| r.strip_suffix(":1)")) { + return crate::i18n::format("Hard-to-read text contrast ({}:1)", &[ratio.to_string()]); + } + match issue { + "Missing slide title" | "Missing alternative text" => crate::i18n::tr(issue).to_string(), + other => other.to_string(), + } +} + #[cfg(test)] #[path = "font_dialog_tests.rs"] mod font_dialog_tests; @@ -4064,3 +4131,22 @@ mod about_credits_tests { #[cfg(test)] #[path = "preferences_tests.rs"] mod preferences_tests; + +#[cfg(test)] +mod issue_text_tests { + use crate::i18n::{Language, set_current}; + + /// The checker's findings read in the interface language, the contrast ratio kept; an unknown + /// finding is shown as the engine wrote it. + #[test] + fn accessibility_findings_follow_the_interface_language() { + set_current(Language::Pl); + assert_eq!(super::issue_text("Missing slide title"), "Brak tytułu slajdu"); + assert_eq!(super::issue_text("Hard-to-read text contrast (2.3:1)"), "Słabo czytelny kontrast tekstu (2.3:1)"); + assert_eq!(super::issue_text("Something new"), "Something new"); + set_current(Language::Uk); + assert_eq!(super::issue_text("Missing alternative text"), "Немає замінного тексту"); + set_current(Language::En); + assert_eq!(super::issue_text("Hard-to-read text contrast (2.3:1)"), "Hard-to-read text contrast (2.3:1)"); + } +} diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index 2410dc0ac..e317a3223 100644 --- a/crates/ui-egui/src/fillui.rs +++ b/crates/ui-egui/src/fillui.rs @@ -111,7 +111,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> let mut sel: usize = ui.data_mut(|d| d.get_temp(sel_id).unwrap_or(0)).min(g.stops.len().saturating_sub(1)); // Preset variations of the first stop's colour. - ui.label("Preset gradients"); + ui.label(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(); @@ -168,7 +168,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> GradientShape::Path { .. } => "Radial", }; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Type")); + let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Type"))); let combo = 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 { @@ -189,12 +189,12 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> let _ = combo.response.labelled_by(field.id); }); ui.horizontal_wrapped(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(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() }); } } @@ -202,7 +202,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() }); } } @@ -211,8 +211,8 @@ 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")); - if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), "Angle", *angle, 15.0, "°", 70.0) { + 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"), crate::i18n::tr("Angle"), *angle, 15.0, "°", 70.0) { out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() }); } }); @@ -220,7 +220,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // Stop bar: click to add, drag a stop to move it. ui.add_space(4.0); - ui.label("Gradient stops"); + ui.label(crate::i18n::tr("Gradient stops")); let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click()); bar_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Gradient bar")); paint_bar(ui, bar, g, sc); @@ -278,7 +278,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // The selected stop. if let Some(s) = g.stops.get(sel) { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Color")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(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); @@ -290,7 +290,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); let can_remove = g.stops.len() > 2; - if ui.add_enabled(can_remove, egui::Button::new("Remove stop")).clicked() { + if ui.add_enabled(can_remove, egui::Button::new(crate::i18n::tr("Remove stop"))).clicked() { let mut ng = g.clone(); ng.stops.remove(sel); sel = sel.saturating_sub(1); @@ -298,8 +298,8 @@ 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")); - if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), "Position", (s.pos * 100.0).round(), 5.0, "%", 70.0) { + 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}"), crate::i18n::tr("Position"), (s.pos * 100.0).round(), 5.0, "%", 70.0) { let mut ng = g.clone(); ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0); ng.stops.sort_by(|a, b| a.pos.total_cmp(&b.pos)); @@ -307,7 +307,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); + let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::i18n::tr("Transparency"))); let key = ui.scope_id().with((id, "tr", sel)); let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32)); let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")).labelled_by(label.id); @@ -474,7 +474,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option let t = Tokens::get(ui.ctx()); let mut out: Option = None; let (fg, bg) = (c32(&pt.fg, sc), c32(&pt.bg, sc)); - ui.label("Pattern"); + ui.label(crate::i18n::tr("Pattern")); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); for name in pattern_gallery() { @@ -492,7 +492,7 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option }); for (label, foreground) in [("Foreground", true), ("Background", false)] { ui.horizontal(|ui| { - ui.add_sized(vec2(80.0, 20.0), egui::Label::new(label)); + ui.add_sized(vec2(80.0, 20.0), egui::Label::new(crate::i18n::tr(label))); let cur = if foreground { &pt.fg } else { &pt.bg }; let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); ui.painter().rect(sw, CornerRadius::same(3), c32(cur, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 000000000..03a5e951b --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,467 @@ +//! 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, + Pl, +} + +impl Language { + pub const ALL: [Self; 4] = [Self::System, Self::En, Self::Uk, Self::Pl]; + + /// The `app.language` value for this choice. + pub fn code(self) -> &'static str { + match self { + Self::System => "system", + Self::En => "en", + Self::Uk => "uk", + Self::Pl => "pl", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::System => tr("System language"), + Self::En => "English", + Self::Uk => "Українська", + Self::Pl => "Polski", + } + } + + 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("pl") { + Self::Pl + } else { + Self::En + } + } + + pub fn resolve(self, locale: Option<&str>) -> Self { + if self == Self::System { Self::from_locale(locale.unwrap_or("")) } else { self } + } +} + +thread_local! { + static CURRENT: Cell = const { Cell::new(Language::En) }; +} + +pub fn current() -> Language { + CURRENT.get() +} + +pub fn set_current(language: Language) { + CURRENT.set(language); +} + +const CATALOG: &str = include_str!("i18n/uk.tsv"); +const PL_CATALOG: &str = include_str!("i18n/pl.tsv"); +static UK: OnceLock> = OnceLock::new(); +static PL: OnceLock> = OnceLock::new(); + +fn parse(text: &str) -> HashMap { + text.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() +} + +fn catalog() -> &'static HashMap { + UK.get_or_init(|| parse(CATALOG)) +} + +fn polish() -> &'static HashMap { + PL.get_or_init(|| parse(PL_CATALOG)) +} + +/// The catalog of the language being drawn; `None` for English. +fn active() -> Option<&'static HashMap> { + match CURRENT.get() { + Language::Uk => Some(catalog()), + Language::Pl => Some(polish()), + Language::System | Language::En => None, + } +} + +/// Exact lookup at an explicit interface boundary; unknown text falls back to English. +pub fn tr(text: &str) -> &str { + active().and_then(|catalog| catalog.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().and_then(|catalog| catalog.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 forms, each as `[one, few, many]`. Ukrainian: 1/21; 2–4/22–24; 0/5–19. +/// Polish: 1 only; 2–4/22–24; 0/5–21 (21 takes the "many" form, unlike Ukrainian). +pub fn count(n: usize, english: &str, ukrainian: [&str; 3], polish: [&str; 3]) -> String { + let few = matches!(n % 10, 2..=4) && !(12..=14).contains(&(n % 100)); + let word = match CURRENT.get() { + Language::Uk if n % 10 == 1 && n % 100 != 11 => ukrainian[0], + Language::Uk if few => ukrainian[1], + Language::Uk => ukrainian[2], + Language::Pl if n == 1 => polish[0], + Language::Pl if few => polish[1], + Language::Pl => polish[2], + Language::System | Language::En => english, + }; + format!("{n} {word}") +} + +/// Recovery notice includes accusative Ukrainian and Polish forms. +pub fn recovered(n: usize) -> String { + let amount = count( + n, + if n == 1 { "unsaved presentation" } else { "unsaved presentations" }, + ["незбережену презентацію", "незбережені презентації", "незбережених презентацій"], + ["niezapisaną prezentację", "niezapisane prezentacje", "niezapisanych prezentacji"], + ); + 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"]); + // Transition options are labelled per transition (the same `l` is "From Right" for Push + // and "Left" for Peel Off); animation options have one label each. + let mut option_labels: Vec = deckcraft_model::anim::TRANSITIONS + .iter() + .flat_map(|effect| effect.4.iter().map(|option| deckcraft_model::anim::transition_option_label(effect.0, option))) + .collect(); + option_labels.extend(deckcraft_model::anim::ANIMATIONS.iter().flat_map(|effect| effect.5).map(|option| crate::ribbon::option_label(option))); + option_labels.retain(|label| !label.is_empty() && !label.chars().all(|c| c.is_ascii_digit())); + option_labels.sort(); + option_labels.dedup(); + let missing: Vec<_> = option_labels.iter().filter(|label| !catalog().contains_key(*label)).collect(); + assert!(missing.is_empty(), "missing effect options: {missing:?}"); + let missing: Vec<_> = option_labels.iter().filter(|label| !polish().contains_key(*label)).collect(); + assert!(missing.is_empty(), "missing Polish effect options: {missing:?}"); + let missing: Vec<_> = required.iter().filter(|key| !catalog().contains_key(**key)).collect(); + assert!(missing.is_empty(), "missing visible metadata: {missing:?}"); + let missing: Vec<_> = required.iter().filter(|key| !polish().contains_key(**key)).collect(); + assert!(missing.is_empty(), "missing visible metadata in Polish: {missing:?}"); + } + + #[test] + fn every_literal_translation_call_has_a_catalog_entry() { + for source in [ + include_str!("canvas.rs"), + include_str!("credits.rs"), + include_str!("dialogs.rs"), + include_str!("fillui.rs"), + include_str!("lib.rs"), + include_str!("media.rs"), + include_str!("menus.rs"), + include_str!("panes.rs"), + include_str!("qat.rs"), + include_str!("ribbon.rs"), + include_str!("show.rs"), + include_str!("sorter.rs"), + include_str!("status.rs"), + include_str!("thumbs.rs"), + include_str!("widgets.rs"), + include_str!("../../../apps/deckcraft/src/main.rs"), + include_str!("../../../apps/deckcraft-web/src/web.rs"), + ] { + for prefix in ["i18n::tr(\"", "i18n::format(\""] { + let mut rest = source.split("#[cfg(test)]").next().unwrap_or(source); + while let Some((_, tail)) = rest.split_once(prefix) { + let mut escaped = false; + let end = tail + .char_indices() + .find_map(|(index, c)| { + if escaped { + escaped = false; + None + } else if c == '\\' { + escaped = true; + None + } else if c == '\"' { + Some(index) + } else { + None + } + }) + .unwrap(); + let key: String = serde_json::from_str(&format!("\"{}\"", &tail[..end])).unwrap(); + assert!(catalog().contains_key(&key), "missing literal translation: {key}"); + assert!(polish().contains_key(&key), "missing Polish literal translation: {key}"); + rest = &tail[end + 1..]; + } + } + } + } + + #[test] + fn language_command_keeps_document_data_and_identifiers_intact() { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("insert.textBox", serde_json::json!({"text": "File {customer} / Звіт", "rect": [1, 2, 100, 40]})).unwrap(); + let before = serde_json::to_value(&app.session.active().unwrap().doc).unwrap(); + app.run("app.language", serde_json::json!({"language": "uk"})).unwrap(); + assert_eq!(app.ui.interface_language, Language::Uk); + assert_eq!(tr("File"), "Файл"); + assert_eq!(before, serde_json::to_value(&app.session.active().unwrap().doc).unwrap()); + assert!(app.run("app.language", serde_json::json!({"language": "invalid"})).is_err()); + assert_eq!(app.ui.interface_language, Language::Uk); + set_current(Language::En); + } + + #[test] + fn bundled_ui_fonts_cover_the_ukrainian_alphabet_without_system_fonts() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name("bold".into()), egui::FontFamily::Monospace] { + // egui 0.36 has_glyph() can report a valid fallback face as a replacement. + // Check the configured fallback and its actual font charmap below instead. + let fallback_installed = ui.ctx().fonts_mut(|fonts| { + fonts.definitions().families.get(&family).is_some_and(|names| names.iter().any(|name| name == "Ubuntu-Light")) + }); + assert!(fallback_installed, "missing bundled Ukrainian fallback in {family:?}"); + } + }); + output.textures_delta.clear(); + let db = deckcraft_fonts::FontDb::with_font_dirs(vec![]); + let face = db.face(deckcraft_fonts::LAST_RESORT_FAMILY, "Regular"); + let letters: std::collections::HashSet<_> = + catalog().values().flat_map(|value| value.chars()).chain("ҐґЄєІіЇї".chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect(); + for c in letters { + assert_ne!(face.glyph_for(c), 0, "missing document fallback glyph: {c}"); + } + } + + // An explicit local visual check; the generated PNGs stay outside the repository. + #[test] + #[ignore = "writes translation review screenshots; run explicitly with DECKCRAFT_I18N_SHOTS"] + fn ukrainian_interface_preview() { + let dir = std::env::var("DECKCRAFT_I18N_SHOTS").unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + for (code, language) in [("en", Language::En), ("uk", Language::Uk), ("pl", Language::Pl)] { + for (name, dialog) in [("editor", None), ("preferences", Some("preferences")), ("table", Some("table"))] { + let mut app = crate::SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.ui.interface_language = language; + app.dialog = dialog.map(crate::dialogs::Dialog::new); + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui(|ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + harness.run_steps(6); + harness.render().unwrap().save(format!("{dir}/{code}-{name}.png")).unwrap(); + } + } + set_current(Language::En); + } + + #[test] + fn locales_and_preferences_are_backward_compatible() { + for locale in ["uk", "uk-UA", "uk_UA.UTF-8", "UK_ua@euro"] { + assert_eq!(Language::from_locale(locale), Language::Uk); + } + for locale in ["", "en-US", "ru-UA", "ukrainian"] { + assert_eq!(Language::from_locale(locale), Language::En); + } + assert_eq!(Language::Uk.resolve(Some("en")), Language::Uk); + let old: crate::UiState = serde_json::from_str("{}").unwrap(); + assert_eq!(old.interface_language, Language::System); + let mut ui = old; + ui.interface_language = Language::Uk; + let round_trip: crate::UiState = serde_json::from_str(&serde_json::to_string(&ui).unwrap()).unwrap(); + assert_eq!(round_trip.interface_language, Language::Uk); + } + + #[test] + fn catalog_is_unique_complete_and_preserves_placeholders() { + for catalog in [CATALOG, PL_CATALOG] { + let mut seen = std::collections::HashSet::new(); + for (line_no, line) in catalog.lines().enumerate() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let (source, text) = line.split_once('\t').unwrap(); + assert!(seen.insert(source), "duplicate on {}: {source}", line_no + 1); + assert!(!text.trim().is_empty(), "empty translation: {source}"); + assert_eq!(placeholders(source), placeholders(text), "placeholder names/order/format: {source}"); + } + } + // Polish translates exactly the keys Ukrainian does, so neither falls back to English. + let mut only_one: Vec<_> = catalog().keys().filter(|key| !polish().contains_key(*key)).collect(); + only_one.extend(polish().keys().filter(|key| !catalog().contains_key(*key))); + assert!(only_one.is_empty(), "keys in one catalog only: {only_one:?}"); + } + + #[test] + fn polish_locales_lookup_and_document_data() { + for locale in ["pl", "pl-PL", "pl_PL.UTF-8", "PL_pl@euro"] { + assert_eq!(Language::from_locale(locale), Language::Pl); + } + assert_eq!(Language::from_locale("plt"), Language::En); + 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} / Raport", "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": "pl"})).unwrap(); + assert_eq!(app.ui.interface_language, Language::Pl); + assert_eq!(tr("File"), "Plik"); + assert_eq!(tr_context("Insert", "@ribbon:Insert"), "Wstawianie"); + assert_eq!(tr("Insert"), "Wstaw"); + assert_eq!(tr("a user-defined slide title"), "a user-defined slide title"); + assert_eq!(before, serde_json::to_value(&app.session.active().unwrap().doc).unwrap()); + let round_trip: crate::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); + assert_eq!(round_trip.interface_language, Language::Pl); + set_current(Language::En); + } + + #[test] + fn polish_integer_counts_differ_from_ukrainian_at_21() { + set_current(Language::Pl); + for (n, word) in [ + (0, "slajdów"), + (1, "slajd"), + (2, "slajdy"), + (4, "slajdy"), + (5, "slajdów"), + (12, "slajdów"), + (14, "slajdów"), + (21, "slajdów"), + (22, "slajdy"), + (25, "slajdów"), + (112, "slajdów"), + (122, "slajdy"), + ] { + assert_eq!(count(n, "slides", ["слайд", "слайди", "слайдів"], ["slajd", "slajdy", "slajdów"]), format!("{n} {word}")); + } + assert_eq!(recovered(3), "Odzyskano 3 niezapisane prezentacje z ostatniej sesji"); + set_current(Language::En); + } + + #[test] + fn the_document_fallback_font_covers_polish_letters_without_system_fonts() { + let db = deckcraft_fonts::FontDb::with_font_dirs(vec![]); + let face = db.face(deckcraft_fonts::LAST_RESORT_FAMILY, "Regular"); + let letters: std::collections::HashSet<_> = polish() + .values() + .flat_map(|value| value.chars()) + .chain("ĄąĆćĘꣳŃńÓóŚśŹźŻż„”".chars()) + .filter(|c| c.is_alphabetic() || "„”".contains(*c)) + .collect(); + for c in letters { + assert_ne!(face.glyph_for(c), 0, "missing document fallback glyph: {c}"); + } + } + + #[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", ["слайд", "слайди", "слайдів"], ["slajd", "slajdy", "slajdów"]), format!("{n} {word}")); + } + set_current(Language::En); + } +} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv new file mode 100644 index 000000000..1e4acd4f9 --- /dev/null +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -0,0 +1,1489 @@ +# Polish interface catalog. English sourcetranslation; \n denotes a line break. +# Contributor-original translation written from the meaning of the English labels; no other +# product's localisation was consulted. MIT OR Apache-2.0. + Fixed date (empty = update automatically): Stała data (puste = aktualizuj automatycznie): + Footer text: Tekst stopki: + Present Pokaż + pt pkt + — Edited — edytowano +"Not Allowed" Symbol Symbol „Zakaz” +% of all commits % wszystkich commitów ++ New Comment + Nowy komentarz ++ New Slide + Nowy slajd ++ Row + Wiersz ++ Series + Seria ++Bin +Przedział + (not checked) (nie sprawdzany) +100% Stacked Bar Słupkowy 100% skumulowany +100% Stacked Column Kolumnowy 100% skumulowany +35mm Slides Slajdy 35 mm +4 Point Star Gwiazda 4-ramienna +5 Point Star Gwiazda 5-ramienna +6 Point Star Gwiazda 6-ramienna +8 Point Star Gwiazda 8-ramienna +@ribbon:Home Narzędzia główne +@ribbon:Insert Wstawianie +@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {} @{}: {} PR-ów, {} commitów, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {} +A3 Paper (297x420 mm) Papier A3 (297x420 mm) +A4 Paper (210x297 mm) Papier A4 (210x297 mm) +Aa Aa +Abc Abc +About Informacje +About DeckCraft DeckCraft — informacje +Accent 1 Akcent 1 +Accent 2 Akcent 2 +Accent 3 Akcent 3 +Accent 4 Akcent 4 +Accent 5 Akcent 5 +Accent 6 Akcent 6 +Accept Revision Zaakceptuj poprawkę +Accessibility Checker Sprawdzanie ułatwień dostępu +Across W poprzek +Action Akcja +Action Button: Custom Przycisk akcji: niestandardowy +Action Button: Document Przycisk akcji: dokument +Action Button: Get Information Przycisk akcji: informacje +Action Button: Go Back Przycisk akcji: wstecz +Action Button: Go Back or Previous Przycisk akcji: wstecz lub poprzedni +Action Button: Go Forward or Next Przycisk akcji: dalej lub następny +Action Button: Go Home Przycisk akcji: początek +Action Button: Go to Beginning Przycisk akcji: przejdź na początek +Action Button: Go to End Przycisk akcji: przejdź na koniec +Action Button: Help Przycisk akcji: pomoc +Action Button: Sound Przycisk akcji: dźwięk +Action Button: Video Przycisk akcji: wideo +Action Button: {k} Przycisk akcji: {k} +Action Buttons Przyciski akcji +Action Settings Ustawienia akcji +Add Animation Dodaj animację +Add Font Dodaj czcionkę +Add Guide Dodaj prowadnicę +Add Horizontal Guide Dodaj prowadnicę poziomą +Add Point Dodaj punkt +Add Section Dodaj sekcję +Add Shape Dodaj kształt +Add to Dictionary Dodaj do słownika +Add to Quick Access Toolbar Dodaj do paska narzędzi Szybki dostęp +Add Vertical Guide Dodaj prowadnicę pionową +Add\nAnimation Dodaj\nanimację +Address: Adres: +Adjust Shape Dostosuj kształt +After Previous Po poprzedniej +After: Po: +Airplane Samolot +Align Wyrównaj +Align Bottom Wyrównaj do dołu +Align Cell Text Wyrównaj tekst w komórce +Align Center Wyrównaj do środka w poziomie +Align Left Wyrównaj do lewej +Align Middle Wyrównaj do środka w pionie +Align Right Wyrównaj do prawej +Align Text Wyrównaj tekst +Align Top Wyrównaj do góry +Alt Text Tekst alternatywny +Alt+ Alt+ +Alt+F9 Alt+F9 +Alt\nText Tekst\nalternatywny +An open-source, clean-room project. Not affiliated with Microsoft. Projekt open source tworzony metodą clean room. Niezwiązany z firmą Microsoft. +Angle Kąt +Animation Gallery Galeria animacji +Animation Pane Okienko animacji +Animation Styles Style animacji +Animation\nPane Okienko\nanimacji +Animations Animacje +Animation Painter Malarz animacji +Apply Animation Zastosuj animację +Play/Pause GIF Odtwórz/wstrzymaj GIF +Animations on Slide Animacje na slajdzie +Appear Pojawienie się +Apply Zastosuj +Apply Design Idea Zastosuj pomysł projektowy +Apply To All Zastosuj do wszystkich +Apply to All Zastosuj do wszystkich +Apply\nTo All Zastosuj\ndo wszystkich +Arc Łuk +Arcs Łuki +Area Warstwowy +Arrange Rozmieść +Arrow Strzałka +Arrow at end Strzałka na końcu +Arrow at start Strzałka na początku +Arrow: Bent Strzałka: zagięta +Arrow: Bent-Up Strzałka: zagięta w górę +Arrow: Chevron Strzałka: szewron +Arrow: Down Strzałka: w dół +Arrow: Left Strzałka: w lewo +Arrow: Left-Right Strzałka: w lewo i w prawo +Arrow: Left-Right-Up Strzałka: w lewo, w prawo i w górę +Arrow: Left-Up Strzałka: w lewo i w górę +Arrow: Notched Right Strzałka: w prawo z wcięciem +Arrow: Pentagon Strzałka: pięciokąt +Arrow: Quad Strzałka: w cztery strony +Arrow: Right Strzałka: w prawo +Arrow: Striped Right Strzałka: w prawo w paski +Arrow: U-Turn Strzałka: zawracanie +Arrow: Up Strzałka: w górę +Arrow: Up-Down Strzałka: w górę i w dół +Arrows Strzałki +Arrows at both ends Strzałki na obu końcach +As One Object Jako jeden obiekt +Audio Dźwięk +Audio from File… Dźwięk z pliku… +AutoCorrect as you type Autokorekta podczas pisania +Autofit Autodopasowanie +Automatic Automatycznie +B4 (ISO) Paper (250x353 mm) Papier B4 (ISO) (250x353 mm) +B5 (ISO) Paper (176x250 mm) Papier B5 (ISO) (176x250 mm) +Background Tło +Background Styles Style tła +Background\nStyles Style\ntła +Backspace Backspace +Banded Columns Kolumny naprzemienne +Banded Rows Wiersze naprzemienne +Banner Transparent +Basic Cycle Cykl podstawowy +Basic List Lista podstawowa +Basic Process Proces podstawowy +Basic Shapes Kształty podstawowe +Before text (in): Przed tekstem (cale): +Bevel Skos +Binary assets added Dodane zasoby binarne +Binary assets removed Usunięte zasoby binarne +Black Left Czarny z lewej +Black Pen Czarne pióro +Black Right Czarny z prawej +Blinds Żaluzje +Block Arc Łuk blokowy +Block Arrows Strzałki blokowe +Blue Niebieski +Blue Pen Niebieskie pióro +Bold Pogrubienie +Bold Flash Błysk pogrubienia +Both Oba +Bottom Dół +Bounce Odbicie +Box Pole +Box In Pole do środka +Box Out Pole na zewnątrz +Box and Whisker Skrzynka i wąsy +Brightness Jasność +Brightness {:+}% Jasność {:+}% +Bring Forward Przesuń do przodu +Bring to Front Przesuń na wierzch +Bring\nForward Przesuń\ndo przodu +Browsed at a kiosk (full screen) Przeglądana w kiosku (pełny ekran) +Browsed by an individual (window) Przeglądana przez użytkownika (okno) +Bubble Bąbelkowy +Bullets Punktory +Burst Wybuch +By Paragraph Akapitami +Callout: Down Arrow Objaśnienie: strzałka w dół +Callout: Left Arrow Objaśnienie: strzałka w lewo +Callout: Line Objaśnienie: linia +Callout: Right Arrow Objaśnienie: strzałka w prawo +Callout: Up Arrow Objaśnienie: strzałka w górę +Callouts Objaśnienia +Cameo Kamea +Camera capture is not available yet Przechwytywanie z kamery nie jest jeszcze dostępne +Cancel Anuluj +Capitalize Each Word Jak Nazwy Własne +Caps Wersaliki +Center Do środka +Centered Wyśrodkowany +Change Case Zmień wielkość liter +Change Chart Type Zmień typ wykresu +Change Picture Zmień obraz +Change Shape Zmień kształt +Change\nChart Type Zmień typ\nwykresu +Character Spacing Odstępy między znakami +Characters Znaki +Chart Wykres +Chart Data Dane wykresu +Chart Design Projekt wykresu +Chart Elements Elementy wykresu +Chart… Wykres… +Check Accessibility Sprawdź ułatwienia dostępu +Check\nAccessibility Sprawdź\nułatwienia dostępu +Checkerboard Szachownica +Chevron Szewron +Choose a SmartArt Graphic Wybierz grafikę SmartArt +Chord Odcinek koła +Circle Okrąg +Circle In Okrąg do środka +Circle Out Okrąg na zewnątrz +Clear All Formatting Wyczyść całe formatowanie +Click a symbol to insert it at the insertion point. Kliknij symbol, aby wstawić go w punkcie wstawiania. +Click icon to add SmartArt graphic Kliknij ikonę, aby dodać grafikę SmartArt +Click icon to add chart Kliknij ikonę, aby dodać wykres +Click icon to add media Kliknij ikonę, aby dodać multimedia +Click icon to add picture Kliknij ikonę, aby dodać obraz +Click icon to add table Kliknij ikonę, aby dodać tabelę +Click to add first slide Kliknij, aby dodać pierwszy slajd +Click to add notes Kliknij, aby dodać notatki +Click to add subtitle Kliknij, aby dodać podtytuł +Click to add text Kliknij, aby dodać tekst +Click to add title Kliknij, aby dodać tytuł +Click to edit Master text styles Kliknij, aby edytować style tekstu wzorca +Click to edit Master title style Kliknij, aby edytować styl tytułu wzorca +Clock Zegar +Clockwise Zgodnie z ruchem wskazówek zegara +Close Zamknij +Close Master View Zamknij widok wzorca +Close\nMaster View Zamknij\nwidok wzorca +Cloud Chmura +Clustered Bar Słupkowy grupowany +Clustered Column Kolumnowy grupowany +Cmd+ Cmd+ +Cmd+, Cmd+, +Cmd+- Cmd+- +Cmd+= Cmd+= +Cmd+F1 Cmd+F1 +Cmd+Shift+P Cmd+Shift+P +Collapse the Ribbon Zwiń wstążkę +Color Kolor +Color At Kolor w punkcie +Color Pulse Pulsowanie koloru +Colors Kolory +Columns Kolumny +Comb Grzebień +Combo Złożony +Command Palette Paleta poleceń +Comment Komentarz +Comments Komentarze +Commits Commity +Community Links Linki do społeczności +Company Firma +Complementary Color Kolor dopełniający +Connect Shapes Połącz kształty +Connection Sites Punkty połączeń +Connector: Curved Łącznik: zakrzywiony +Connector: Elbow Łącznik: łamany +Connector: Elbow (one bend) Łącznik: łamany (jedno zagięcie) +Content Zawartość +Contract Zwiń +Contrast Kontrast +Contrast {:+}% Kontrast {:+}% +Contributors Współtwórcy +Conveyor Taśmociąg +Copy Kopiuj +Corrections Korekty +Couldn't save AutoRecover information: {e} Nie udało się zapisać informacji Autoodzyskiwania: {e} +Counter Clockwise Przeciwnie do ruchu wskazówek zegara +Cover Przykrycie +Create Utwórz +Crop Przytnij +Crop 10% each side Przytnij 10% z każdej strony +Crop 20% each side Przytnij 20% z każdej strony +Crop to Shape Przytnij do kształtu +Cross Krzyż +Crush Zgniecenie +Ctrl+ Ctrl+ +Cube Sześcian +Currency Walutowy +Curtains Kurtyny +Custom Niestandardowy +Custom Path Ścieżka niestandardowa +Custom Show Pokaz niestandardowy +Custom Show 1 Pokaz niestandardowy 1 +Custom Shows Pokazy niestandardowe +Custom Slide Show Niestandardowy pokaz slajdów +Custom\nShow Pokaz\nniestandardowy +Cut Wytnij +Cylinder Walec +Dark Blue Ciemnoniebieski +Dark Mode Tryb ciemny +Dark Red Ciemnoczerwony +Dark appearance Ciemny wygląd +Dark\nMode Tryb\nciemny +Darken Przyciemnienie +Dash Kreska +Dash Dot Kreska-kropka +Dashes Kreski +Database Baza danych +Date Data +Date & Time Data i godzina +Date &\nTime Data\ni godzina +Date and Time Data i godzina +Date and time Data i godzina +Decagon Dziesięciokąt +Decision Decyzja +DeckCraft DeckCraft +Decrease Font Size Zmniejsz rozmiar czcionki +Decrease List Level Zmniejsz poziom listy +Delay: Opóźnienie: +Delete Usuń +Delete Columns Usuń kolumny +Delete Comment Usuń komentarz +Delete Layout Usuń układ +Delete Rows Usuń wiersze +Delete Slide Usuń slajd +Delete Table Usuń tabelę +Delete Text Usuń tekst +Desaturate Zmniejszenie nasycenia +Deselect Odznacz +Design Projektowanie +Design Ideas Pomysły projektowe +Design\nIdeas Pomysły\nprojektowe +Deutsch Deutsch +Diagonal Stripe Pasek ukośny +Diamond Romb +Diamond In Romb do środka +Diamond Out Romb na zewnątrz +Direction Kierunek +Disappear Zniknięcie +Discard Recovered Presentations Odrzuć odzyskane prezentacje +Display name Nazwa wyświetlana +Dissolve Rozpuszczanie +Dissolve In Rozpuszczanie do środka +Dissolve Out Rozpuszczanie na zewnątrz +Distribute Rozłóż +Distribute Columns Rozłóż kolumny +Distribute Horizontally Rozłóż w poziomie +Distribute Rows Rozłóż wiersze +Distribute Vertically Rozłóż w pionie +Division Sign Znak dzielenia +Do not Autofit Nie dopasowuj automatycznie +Do you want to save the changes you made to {what}? Czy chcesz zapisać zmiany wprowadzone w {what}? +Document Dokument +Dodecagon Dwunastokąt +Don't Save Nie zapisuj +Don't show on title slide Nie pokazuj na slajdzie tytułowym +Done Gotowe +Donut Pierścień +Doors Drzwi +Double Brace Podwójny nawias klamrowy +Double Bracket Podwójny nawias kwadratowy +Double Wave Podwójna fala +Doughnut Pierścieniowy +Down W dół +Drape Draperia +Draw Rysowanie +Drop Shadow Rectangle Prostokąt z cieniem +Duplicate Duplikuj +Duplicate Selected Slides Duplikuj zaznaczone slajdy +Duplicate Slide Duplikuj slajd +Duration: Czas trwania: +Dynamic Content Zawartość dynamiczna +Edit Edycja +Edit Alt Text… Edytuj tekst alternatywny… +Edit Data Edytuj dane +Edit Shape Edytuj kształt +Edit Text Edytuj tekst +Edit the data: first row = series names, first column = categories. Edytuj dane: pierwszy wiersz = nazwy serii, pierwsza kolumna = kategorie. +Edit\nData Edytuj\ndane +Edit\nShape Edytuj\nkształt +Editing Edytowanie +Effect Options Opcje efektu +Effect\nOptions Opcje\nefektu +Effects Efekty +Email Address Adres e-mail +Email address: Adres e-mail: +Emphasis Wyróżnienie +End Koniec +End Show Zakończ pokaz +End of slide show Koniec pokazu slajdów +End of slide show, click to exit. Koniec pokazu slajdów, kliknij, aby zakończyć. +End trim (s): Przycięcie końca (s): +English (United Kingdom) English (United Kingdom) +English (United States) English (United States) +Enter Enter +Entrance Wejście +Equals Znak równości +Equation Równanie +Equation Shapes Kształty równań +Eraser Gumka +Escape Escape +Español Español +Exciting Efektowne +Exit Wyjście +Expand Rozwiń +Explosion: 14 Points Eksplozja: 14 ramion +Explosion: 8 Points Eksplozja: 8 ramion +Export Eksport +Export the presentation as: Eksportuj prezentację jako: +Exported {path} Wyeksportowano {path} +Export… Eksportuj… +Fade Zanikanie +Fade in (s): Narastanie (s): +Fade out (s): Wyciszanie (s): +Fall Over Przewrócenie +Ferris Wheel Diabelski młyn +File Plik +Fill Wypełnienie +Fill & Line Wypełnienie i linia +Fill Color Kolor wypełnienia +Filled Radar Radarowy wypełniony +Find… Znajdź… +First Pierwszy +First Column Pierwsza kolumna +First Slide Pierwszy slajd +First commit Pierwszy commit +Fit Dopasuj +Fit slide to current window Dopasuj slajd do bieżącego okna +Fit to Window Dopasuj do okna +Fit to\nWindow Dopasuj\ndo okna +Flash Błysk +Flip Przerzucenie +Flip Horizontal Przerzuć w poziomie +Flip Vertical Przerzuć w pionie +Float In Wpłynięcie +Float Out Wypłynięcie +Flowchart Schemat blokowy +Flowchart: Alternate Process Schemat blokowy: proces alternatywny +Flowchart: Card Schemat blokowy: karta +Flowchart: Collate Schemat blokowy: sortowanie zbiorcze +Flowchart: Connector Schemat blokowy: łącznik +Flowchart: Data Schemat blokowy: dane +Flowchart: Decision Schemat blokowy: decyzja +Flowchart: Delay Schemat blokowy: opóźnienie +Flowchart: Direct Access Storage Schemat blokowy: pamięć o dostępie bezpośrednim +Flowchart: Display Schemat blokowy: wyświetlacz +Flowchart: Document Schemat blokowy: dokument +Flowchart: Extract Schemat blokowy: wyodrębnianie +Flowchart: Internal Storage Schemat blokowy: pamięć wewnętrzna +Flowchart: Magnetic Disk Schemat blokowy: dysk magnetyczny +Flowchart: Manual Input Schemat blokowy: ręczne wprowadzanie danych +Flowchart: Manual Operation Schemat blokowy: operacja ręczna +Flowchart: Merge Schemat blokowy: scalanie +Flowchart: Multidocument Schemat blokowy: wiele dokumentów +Flowchart: Off-page Connector Schemat blokowy: łącznik międzystronicowy +Flowchart: Or Schemat blokowy: lub +Flowchart: Predefined Process Schemat blokowy: proces zdefiniowany +Flowchart: Preparation Schemat blokowy: przygotowanie +Flowchart: Process Schemat blokowy: proces +Flowchart: Punched Tape Schemat blokowy: taśma perforowana +Flowchart: Sequential Access Storage Schemat blokowy: pamięć o dostępie sekwencyjnym +Flowchart: Sort Schemat blokowy: sortowanie +Flowchart: Stored Data Schemat blokowy: dane przechowywane +Flowchart: Summing Junction Schemat blokowy: węzeł sumujący +Flowchart: Terminator Schemat blokowy: zakończenie +Fly In Wlot +Fly Out Wylot +Fly Through Przelot +Folded Corner Zagięty narożnik +Follow the layout Zgodnie z układem +Followed Hyperlink Odwiedzone hiperłącze +Font Czcionka +Font Color Kolor czcionki +Font Size Rozmiar czcionki +Fonts Czcionki +Footer Stopka +Foreground Pierwszy plan +Format Formatowanie +Format Background Formatuj tło +Format Background… Formatuj tło… +Format Painter Malarz formatów +Format Painter (double-click to keep it on) Malarz formatów (kliknij dwukrotnie, aby pozostał włączony) +Format Pane Okienko formatowania +Format Shape Formatuj kształt +Format Shape… Formatuj kształt… +Format\nBackground Formatuj\ntło +Format\nPane Okienko\nformatowania +Formatting State Stan formatowania +Fracture Pęknięcie +Frame Ramka +Français Français +Freeform Dowolny kształt +From Od +From Beginning Od początku +From Bottom Od dołu +From Bottom Left Od dołu z lewej +From Bottom Right Od dołu z prawej +From Current Slide Od bieżącego slajdu +From Left Od lewej +From Right Od prawej +From Top Od góry +From Top Left Od góry z lewej +From Top Right Od góry z prawej +From bottom left Od lewego dolnego rogu +From bottom right Od prawego dolnego rogu +From center Od środka +From top left Od lewego górnego rogu +From top right Od prawego górnego rogu +From\nBeginning Od\npoczątku +From\nCurrent Slide Od bieżącego\nslajdu +Full Pełne +Full Page Slides Slajdy na całą stronę +Funnel Lejkowy +Gallery Galeria +General Ogólne +Get Text Pobierz tekst +Glitter Brokat +Glow Poświata +Go to Przejdź do +Go to Slide Przejdź do slajdu +Grab bag Mieszanka +Gradient fill Wypełnienie gradientowe +Gradient stops Punkty gradientu +Gradient… Gradient… +Grayscale Skala szarości +Greek Grecki +Green Zielony +Gridlines Linie siatki +Group Grupuj +Group Objects Grupuj obiekty +Grow & Turn Powiększenie i obrót +Grow/Shrink Powiększenie/zmniejszenie +Guides Prowadnice +Half Połowiczne +Half Frame Pół ramki +Handout Master Wzorzec materiałów informacyjnych +Handout Master (coming soon) Wzorzec materiałów informacyjnych (wkrótce) +Handout\nMaster Wzorzec\nmateriałów +Handouts (1 slide per page) Materiały informacyjne (1 slajd na stronie) +Handouts (2 slides per page) Materiały informacyjne (2 slajdy na stronie) +Handouts (3 slides per page) Materiały informacyjne (3 slajdy na stronie) +Handouts (4 slides per page) Materiały informacyjne (4 slajdy na stronie) +Handouts (6 slides per page) Materiały informacyjne (6 slajdów na stronie) +Handouts (9 slides per page) Materiały informacyjne (9 slajdów na stronie) +Header Nagłówek +Header & Footer Nagłówek i stopka +Header &\nFooter Nagłówek\ni stopka +Header Row Wiersz nagłówka +Header and Footer Nagłówek i stopka +Header and Footer… Nagłówek i stopka… +Heart Serce +Height Wysokość +Height (in): Wysokość (cale): +Height: Wysokość: +Help Pomoc +Heptagon Siedmiokąt +Hexagon Sześciokąt +Hide All Ukryj wszystko +Hide Background Graphics Ukryj grafikę tła +Hide During Show Ukryj podczas pokazu +Hide Slide Ukryj slajd +Hide background graphics Ukryj grafikę tła +Hide\nSlide Ukryj\nslajd +Hierarchy Hierarchia +High Wysoka +Highlighter Zakreślacz +Histogram Histogram +Home Narzędzia główne +Honeycomb Plaster miodu +Horizontal Poziomo +Horz In Poziomo do środka +Horz Out Poziomo na zewnątrz +How would you describe this object and its context to someone who is blind or has low vision? Jak opiszesz ten obiekt i jego kontekst osobie niewidomej lub słabowidzącej? +Hyperlink Hiperłącze +Icons Ikony +In Do środka +In Bounce Odbicie do środka +Include hidden slides Uwzględnij ukryte slajdy +Increase Font Size Zwiększ rozmiar czcionki +Increase List Level Zwiększ poziom listy +Ink to Math Pismo odręczne na równanie +Ink to Math (coming soon) Pismo odręczne na równanie (wkrótce) +Ink to Shape Pismo odręczne na kształt +Ink to Shape (coming soon) Pismo odręczne na kształt (wkrótce) +Ink to Text Pismo odręczne na tekst +Ink to Text (coming soon) Pismo odręczne na tekst (wkrótce) +Ink to\nMath Odręczne\nna równanie +Ink to\nShape Odręczne\nna kształt +Ink to\nText Odręczne\nna tekst +Inner Wewnętrzny +Insert Wstaw +Insert Above Wstaw powyżej +Insert Below Wstaw poniżej +Insert Hyperlink Wstaw hiperłącze +Insert Layout Wstaw układ +Insert Left Wstaw z lewej +Insert Placeholder Wstaw symbol zastępczy +Insert Right Wstaw z prawej +Insert Table Wstaw tabelę +Insert Table… Wstaw tabelę… +Insert picture from file… Wstaw obraz z pliku… +Insert\nAbove Wstaw\npowyżej +Insert\nBelow Wstaw\nponiżej +Insert\nLayout Wstaw\nukład +Insert\nLeft Wstaw\nz lewej +Insert\nPlaceholder Wstaw symbol\nzastępczy +Insert\nRight Wstaw\nz prawej +Inspect Presentation Sprawdź prezentację +Inspect Shape Sprawdź kształt +Inspect Slide Sprawdź slajd +Interface Language Język interfejsu +Interface language: Język interfejsu: +Interface size Rozmiar interfejsu +Isosceles Triangle Trójkąt równoramienny +Italiano Italiano +Italic Kursywa +JPEG image (current slide) Obraz JPEG (bieżący slajd) +Join our community on Discord Dołącz do naszej społeczności na Discordzie +Join the ArtCraft community on Discord Dołącz do społeczności ArtCraft na Discordzie +Justify Wyjustuj +L-Shape Kształt L +Language Język +Last Ostatni +Last Column Ostatnia kolumna +Last Slide Ostatni slajd +Last commit Ostatni commit +Layout Układ +Learn More Dowiedz się więcej +Learn\nMore Dowiedz się\nwięcej +Ledger Paper (11x17 in) Papier Ledger (11x17 cali) +Left Do lewej +Left Brace Lewy nawias klamrowy +Left Bracket Lewy nawias kwadratowy +Letter Paper (8.5x11 in) Papier Letter (8,5x11 cali) +Liberation Serif Liberation Serif +Light Blue Jasnoniebieski +Light Green Jasnozielony +Light/Dark appearance Wygląd jasny/ciemny +Lighten Rozjaśnienie +Lightning Błyskawica +Lightning Bolt Błyskawica +Line Liniowy +Line Color Kolor linii +Line Spacing Interlinia +Line Spacing Options… Opcje interlinii… +Line delta Bilans wierszy +Line spacing (lines): Interlinia (wiersze): +Line with Markers Liniowy ze znacznikami +Linear Liniowy +Lines Linie +Lines +/− Wiersze +/− +Lines added Dodane wiersze +Lines deleted Usunięte wiersze +Link Łącze +Link… Łącze… +List Commands Lista poleceń +Lock Zablokuj +Long Dash Długa kreska +Long Dash Dot Długa kreska-kropka +Loop continuously until 'Esc' W pętli ciągłej do naciśnięcia klawisza „Esc” +Loop until Stopped W pętli do zatrzymania +Loops Pętle +Loose Luźne +Low Niska +MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors MIT OR Apache-2.0 · © 2026 ArtCraft Team i współtwórcy DeckCraft +Margins (pt) Marginesy (pkt) +Mark as decorative Oznacz jako dekoracyjny +Mark selected text as: Oznacz zaznaczony tekst jako: +Math Matematyka +Matrix Macierz +Maximize content (else ensure fit) Maksymalizuj zawartość (w przeciwnym razie tylko dopasuj) +Media Multimedia +Media Info Informacje o multimediach +Medium Średnia +Merge Cells Scal komórki +Merge Shapes Scal kształty +Merge\nCells Scal\nkomórki +Merge\nShapes Scal\nkształty +Merged PRs Scalone PR-y +Middle Środek +Minus Sign Znak minus +Model Model +Models Modele +Moon Księżyc +More Colors… # Więcej kolorów… # +Morph Przekształcenie +Motion Ruch +Motion Paths Ścieżki ruchu +Move Przenieś +Move Down Przenieś w dół +Move Insertion Point Przenieś punkt wstawiania +Move Section Przenieś sekcję +Move Section Down Przenieś sekcję w dół +Move Section Up Przenieś sekcję w górę +Move Slide Przenieś slajd +Move Up Przenieś w górę +Multiplication Sign Znak mnożenia +Multiply Mnożenie +Mute Wycisz +Name Nazwa +Name (A–Z) Nazwa (A–Z) +Name: Nazwa: +New Comment Nowy komentarz +New Presentation Nowa prezentacja +New Slide Nowy slajd +New Slide (⇧⌘N) Nowy slajd (⇧⌘N) +New presentation Nowa prezentacja +New show name: Nazwa nowego pokazu: +New\nComment Nowy\nkomentarz +New\nSlide Nowy\nslajd +Next Następny +Next Slide Następny slajd +Next Window Następne okno +Next ▶ Następny ▶ +No Color Brak koloru +No Fill Brak wypełnienia +No Glow Brak poświaty +No Outline Brak konturu +No Reflection Brak odbicia +No Shadow Brak cienia +No Soft Edges Brak miękkich krawędzi +No accessibility issues found. People with disabilities should not have difficulty reading this document. Nie znaleziono problemów z ułatwieniami dostępu. Osoby z niepełnosprawnościami nie powinny mieć trudności z czytaniem tego dokumentu. +No comments on this slide. Brak komentarzy na tym slajdzie. +No contributor data was built into this copy. Ta kopia nie zawiera danych o współtwórcach. +No fill Brak wypełnienia +No line Brak linii +No model credits were built into this copy. Ta kopia nie zawiera informacji o modelach. +No notes. Brak notatek. +No sound: {err} Brak dźwięku: {err} +None Brak +Normal Normalny +Not Equal Znak nierówności +Not allowed Zakaz +Notes Notatki +Notes Master Wzorzec notatek +Notes Master (coming soon) Wzorzec notatek (wkrótce) +Notes Page Strona notatek +Notes Pages Strony notatek +Notes\nMaster Wzorzec\nnotatek +Notes\nPage Strona\nnotatek +Nudge Przesuń lekko +Number of columns: Liczba kolumn: +Number of rows: Liczba wierszy: +Numbering Numerowanie +Object Color Kolor obiektu +Objects Obiekty +Octagon Ośmiokąt +On Click Po kliknięciu +On Click of {name} Po kliknięciu: {name} +On Click of… (none) Po kliknięciu… (brak) +On Mouse Click Po kliknięciu myszą +On-screen Show (16:10) Pokaz na ekranie (16:10) +Open Data Otwórz dane +Open Dialog Otwórz okno dialogowe +Open Recovered Presentations Otwórz odzyskane prezentacje +Open the sample deck Otwórz przykładową prezentację +Open… Otwórz… +Orange Pomarańczowy +Orbit Orbita +Order Objects Kolejność obiektów +Origami Origami +Out Na zewnątrz +Out Bounce Odbicie na zewnątrz +Outer Zewnętrzny +Outline Kontur +Outline (.txt) Konspekt (.txt) +Outline View Widok konspektu +Outline\nView Widok\nkonspektu +Oval Owal +Oval ends Owalne zakończenia +Overhead Folia +PDF document (.pdf) Dokument PDF (.pdf) +PNG images (all slides) Obrazy PNG (wszystkie slajdy) +PNG images (current slide) Obrazy PNG (bieżący slajd) +PRs PR-y +Page Curl Zawinięcie strony +Page Setup… Ustawienia strony… +PageDown PageDown +PageUp PageUp +Pan Panorama +Pane Okienko +Paragraph Akapit +Paragraph… Akapit… +Parallelogram Równoległobok +Pareto Pareto +Partial Circle Wycinek koła +Particles Cząsteczki +Paste Wklej +Paste (⌘V) Wklej (⌘V) +Paste Formatting Wklej formatowanie +Paste and Match Formatting Wklej i dopasuj formatowanie +Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets. Wklej lub wpisz konspekt. Wiersze bez wcięcia stają się tytułami slajdów, a wiersze wcięte tabulatorem — punktami listy. +Pasted image.png Wklejony obraz.png +Path Ścieżka +Pattern fill Wypełnienie deseniem +Pause Wstrzymaj +Peek In Wsunięcie +Peek Out Wysunięcie +Peel Off Odklejenie +Pencil Ołówek +Pentagon Pięciokąt +Perspective Perspektywa +Pick a graphic; type one item per line. Wybierz grafikę i wpisz po jednym elemencie w wierszu. +Pick a look for your slides. Ideas are generated on your device. Wybierz wygląd slajdów. Pomysły są generowane na Twoim urządzeniu. +Picture Obraz +Picture Format Formatowanie obrazu +Picture Styles Style obrazu +Picture from File… Obraz z pliku… +Picture or texture fill Wypełnienie obrazem lub teksturą +Picture\nStyles Style\nobrazu +Pictures Obrazy +Pie Kołowy +Pin Przypnij +Placeholder colour Kolor symbolu zastępczego +Plan\nBuild\nLaunch Planuj\nBuduj\nWdrażaj +Plaque Plakietka +Play Odtwórz +Play Across Slides Odtwarzaj na kolejnych slajdach +Play Full Screen Odtwarzaj na pełnym ekranie +Play Narrations Odtwarzaj narrację +Play from Current Slide Odtwórz od bieżącego slajdu +Play from Start Odtwórz od początku +Play from Start (F5) Odtwórz od początku (F5) +Play from\nCurrent Slide Od bieżącego\nslajdu +Play from\nStart Od\npoczątku +Play/Pause Odtwórz/wstrzymaj +Playback Odtwarzanie +Plus Plus +Plus In Plus do środka +Plus Out Plus na zewnątrz +Plus Sign Znak plus +Portrait (9:16) Pionowo (9:16) +Português Português +Position Położenie +Position Objects Położenie obiektów +Position and Size Położenie i rozmiar +Post Opublikuj +Poster Frame Klatka tytułowa +PowerPoint Presentation (.pptx) Prezentacja programu PowerPoint (.pptx) +Preferences Preferencje +Preferences… Preferencje… +Presentation Prezentacja +Presentations Prezentacje +Presentations and slide shows, rebuilt from scratch in pure Rust. Prezentacje i pokazy slajdów, napisane od zera w czystym języku Rust. +Presented by a speaker (full screen) Prezentowana przez prelegenta (pełny ekran) +Presenter View Widok prezentera +Presenter View — DeckCraft Widok prezentera — DeckCraft +Presenter\nView Widok\nprezentera +Preset gradients Gotowe gradienty +Prestige Prestiż +Preview Podgląd +Previous Slide Poprzedni slajd +Print layout: Układ wydruku: +Properties Właściwości +Pulse Pulsowanie +Punctuation Interpunkcja +Purple Fioletowy +Push Wypchnięcie +Pyramid Piramida +Quick Styles Szybkie style +Quick\nStyles Szybkie\nstyle +Radar Radarowy +Radial Promienisty +Random Losowo +Random Bars Losowe paski +Reading View Widok do czytania +Reading\nView Widok\ndo czytania +Real name Imię i nazwisko +Recent Ostatnie +Record Nagraj +Recording narration needs audio capture (coming soon) Nagrywanie narracji wymaga przechwytywania dźwięku (wkrótce) +Recovered Presentations Odzyskane prezentacje +Recovered {} from the last session Odzyskano {} z ostatniej sesji +Rectangle Prostokąt +Rectangle: Diagonal Corners Rounded Prostokąt: zaokrąglone rogi po przekątnej +Rectangle: Diagonal Corners Snipped Prostokąt: ścięte rogi po przekątnej +Rectangle: Rounded Corners Prostokąt: zaokrąglone rogi +Rectangle: Single Corner Rounded Prostokąt: jeden róg zaokrąglony +Rectangle: Single Corner Snipped Prostokąt: jeden róg ścięty +Rectangle: Top Corners One Rounded and One Snipped Prostokąt: górne rogi, jeden zaokrąglony i jeden ścięty +Rectangle: Top Corners Rounded Prostokąt: zaokrąglone górne rogi +Rectangle: Top Corners Snipped Prostokąt: ścięte górne rogi +Rectangles Prostokąty +Rectangular Prostokątny +Red Czerwony +Red Pen Czerwone pióro +Redo Ponów +Redo (⌘Y) Ponów (⌘Y) +Reflected Rounded Rectangle Odbity prostokąt zaokrąglony +Reflection Odbicie +Regroup Grupuj ponownie +Regular Pentagon Pięciokąt foremny +Rehearse Timings Próba tempa +Rehearse\nTimings Próba\ntempa +Remove Usuń +Remove All Animations Usuń wszystkie animacje +Remove All Sections Usuń wszystkie sekcje +Remove Animation Usuń animację +Remove Background Usuń tło +Remove Background (coming soon) Usuń tło (wkrótce) +Remove Section Usuń sekcję +Remove Section & Slides Usuń sekcję i slajdy +Remove stop Usuń punkt gradientu +Remove\nBackground Usuń\ntło +Rename Zmień nazwę +Rename Layout Zmień nazwę układu +Rename Section Zmień nazwę sekcji +Rename Section… Zmień nazwę sekcji… +Rename Slide Zmień nazwę slajdu +Rename… Zmień nazwę… +Render Slide Renderuj slajd +Reopen Otwórz ponownie +Reorder Animation Zmień kolejność animacji +Reorder Objects Zmień kolejność obiektów +Replace straight quotes with smart quotes Zamieniaj cudzysłowy proste na drukarskie +Reply Odpowiedz +Reply… Odpowiedz… +Reset Resetuj +Reset Background Resetuj tło +Reset Crop Resetuj przycięcie +Reset Effects Resetuj efekty +Reset Picture Resetuj obraz +Reset Slide Resetuj slajd +Reset Slide Background Resetuj tło slajdu +Resize shape to fit text Dopasuj rozmiar kształtu do tekstu +Resolve Rozwiąż +Resolve Thread Rozwiąż wątek +Return Return +Reveal Odsłonięcie +Reverse the order Odwróć kolejność +Revert Przywróć +Review Recenzja +Rewind after Playing Przewiń po odtworzeniu +Ribbon Tab Karta wstążki +Ribbon: Tilted Down Wstęga: pochylona w dół +Ribbon: Tilted Up Wstęga: pochylona w górę +Right Do prawej +Right Brace Prawy nawias klamrowy +Right Bracket Prawy nawias kwadratowy +Right Triangle Trójkąt prostokątny +Right-to-Left Text Direction Kierunek tekstu od prawej do lewej +Ring Pierścień +Ripple Zmarszczki +Rise Up Uniesienie +Rotate Obróć +Rotate Left 90° Obróć w lewo o 90° +Rotate Right 90° Obróć w prawo o 90° +Rotate all text 270° Obróć cały tekst o 270° +Rotate all text 90° Obróć cały tekst o 90° +Rotate with shape Obracaj razem z kształtem +Rotation Obrót +Round Dot Okrągła kropka +Rounded Diagonal Corner Zaokrąglony róg po przekątnej +Ruler Linijka +Saturation Nasycenie +Saturation {}% Nasycenie {}% +Save Zapisz +Save (⌘S) Zapisz (⌘S) +Save As… Zapisz jako… +Save AutoRecover Information Zapisz informacje Autoodzyskiwania +Save as Template… Zapisz jako szablon… +Save to Data Zapisz do danych +Saved Zapisano +Saved {path} Zapisano {path} +Scatter Punktowy +ScreenTip: Etykietka ekranowa: +Screenshot Zrzut ekranu +Screenshot (not available yet) Zrzut ekranu (jeszcze niedostępny) +Scroll: Horizontal Zwój: poziomy +Scroll: Vertical Zwój: pionowy +Search commands (⇧⌘P) Szukaj poleceń (⇧⌘P) +Search commands… Szukaj poleceń… +Section Sekcja +Seek Przejdź do pozycji +Select Zaznacz +Select All Zaznacz wszystko +Select All Text Zaznacz cały tekst +Select Cells Zaznacz komórki +Select Objects Zaznacz obiekty +Select Paragraph Zaznacz akapit +Select Slides Zaznacz slajdy +Select Text Zaznacz tekst +Select Word Zaznacz wyraz +Select a chart first. Najpierw zaznacz wykres. +Select a shape to format it. Zaznacz kształt, aby go sformatować. +Select an object on the slide and add an animation from the Animations tab. Zaznacz obiekt na slajdzie i dodaj animację z karty Animacje. +Selection Zaznaczenie +Selection Pane Okienko zaznaczenia +Selection Pane… Okienko zaznaczenia… +Selection\nPane Okienko\nzaznaczenia +Send Backward Przesuń do tyłu +Send to Back Przesuń na spód +Send\nBackward Przesuń\ndo tyłu +Sentence case. Jak w zdaniu. +Set Text Ustaw tekst +Set Up Show… Przygotuj pokaz… +Set Up Slide Show Przygotuj pokaz slajdów +Set Up Slide Show… Przygotuj pokaz slajdów… +Set Up\nSlide Show Przygotuj\npokaz slajdów +Set as Default Shape Ustaw jako kształt domyślny +Shading Cieniowanie +Shadow Cień +Shape Kształt +Shape Effects Efekty kształtów +Shape Fill Wypełnienie kształtu +Shape Format Formatowanie kształtu +Shape Gallery Galeria kształtów +Shape Outline Kontur kształtu +Shape Styles Style kształtów +Shape {} Kształt {} +Shape\nStyles Style\nkształtów +Shapes Kształty +Shift+ Shift+ +Shift+F9 Shift+F9 +Show Pokaż +Show All Pokaż wszystko +Show Comments Pokaż komentarze +Show Media Controls Pokaż kontrolki multimediów +Show Slide Pokaż slajd +Show smart guides Pokaż inteligentne prowadnice +Show type Typ pokazu +Show without animation Pokaż bez animacji +Show without narration Pokaż bez narracji +Show/Hide Pokaż/ukryj +Show\nComments Pokaż\nkomentarze +Shred Niszczarka +Shrink & Turn Zmniejszenie i obrót +Shrink text on overflow Zmniejszaj tekst przy przepełnieniu +Simple Frame, White Prosta ramka, biała +Sink Down Opadnięcie +Size Rozmiar +Size & Properties Rozmiar i właściwości +Slide Master Wzorzec slajdów +Slide Number Numer slajdu +Slide Show Pokaz slajdów +Slide Size Rozmiar slajdu +Slide Sorter Sortowanie slajdów +Slide number Numer slajdu +Slide timings recorded. Zapisano chronometraż slajdów. +Slide {:02}:{:04.1} Total {:02}:{:02} Slajd {:02}:{:04.1} Razem {:02}:{:02} +Slide {} of {} Slajd {} z {} +Slide {}: {}{} Slajd {}: {}{} +Slide {}: “{w}” Slajd {}: „{w}” +Slide\nMaster Wzorzec\nslajdów +Slide\nNumber Numer\nslajdu +Slide\nSize Rozmiar\nslajdu +Slide\nSorter Sortowanie\nslajdów +Slides (e.g. 1,3,5): Slajdy (np. 1,3,5): +Slides from Outline Slajdy z konspektu +Slides from Outline… Slajdy z konspektu… +Slides sized for: Rozmiar slajdów dla: +SmartArt SmartArt +Smile Uśmiech +Smiley Face Uśmiechnięta buźka +Smoothly Płynnie +Smoothly Left Płynnie w lewo +Smoothly Right Płynnie w prawo +Snap objects to grid Przyciągaj obiekty do siatki +Soft Edge Rectangle Prostokąt o miękkich krawędziach +Soft Edges Miękkie krawędzie +Solid Ciągła +Solid fill Wypełnienie pełne +Solid line Linia ciągła +Sort Sortuj +Source on GitHub Kod źródłowy na GitHubie +Space Spacja +Spacing after (pt): Odstęp po (pkt): +Spacing before (pt): Odstęp przed (pkt): +Special — hanging (in): Specjalne — wysunięcie (cale): +Speech Mowa +Speech Bubble: Oval Dymek mowy: owalny +Speech Bubble: Rectangle Dymek mowy: prostokątny +Speech Bubble: Rectangle with Corners Rounded Dymek mowy: prostokątny z zaokrąglonymi rogami +Spelling Pisownia +Spin Obrót +Spinner Wirowanie +Split Podział +Split Cells Podziel komórki +Split\nCells Podziel\nkomórki +Square Kwadrat +Square (1:1) Kwadrat (1:1) +Square Dot Kwadratowa kropka +Stacked Skumulowany +Stacked Area Warstwowy skumulowany +Stacked Bar Słupkowy skumulowany +Stacked Column Kolumnowy skumulowany +Stacked Line Liniowy skumulowany +Standard (4:3) Standardowy (4:3) +Standard Colors Kolory standardowe +Star Gwiazda +Star: 10 Points Gwiazda: 10 ramion +Star: 12 Points Gwiazda: 12 ramion +Star: 16 Points Gwiazda: 16 ramion +Star: 24 Points Gwiazda: 24 ramiona +Star: 32 Points Gwiazda: 32 ramiona +Star: 4 Points Gwiazda: 4 ramiona +Star: 5 Points Gwiazda: 5 ramion +Star: 6 Points Gwiazda: 6 ramion +Star: 7 Points Gwiazda: 7 ramion +Star: 8 Points Gwiazda: 8 ramion +Stars and Banners Gwiazdy i transparenty +Start After Previous Rozpocznij po poprzedniej +Start Automatically Rozpocznij automatycznie +Start On Click Rozpocznij po kliknięciu +Start With Previous Rozpocznij z poprzednią +Start a conversation Rozpocznij rozmowę +Start time (s): Czas rozpoczęcia (s): +Start: Początek: +Stealth end Zakończenie ostre +Stock Giełdowy +Stop Zatrzymaj +Stop Editing Text Zakończ edycję tekstu +Strikethrough Przekreślenie +Strips Paski +Style {i} Styl {i} +Subscript Indeks dolny +Subtle Subtelne +Sun Słońce +Sunburst Pierścieniowy wielopoziomowy +Superscript Indeks górny +Surface Powierzchniowy +Switch Przełączenie +Swivel Obrót wokół osi +Symbol Symbol +System language Język systemu +Tab Tab +Table Tabela +Table Design Projekt tabeli +Table Style Options Opcje stylu tabeli +Table Styles Style tabeli +Table… Tabela… +Task Pane Okienko zadań +Teardrop Łza +Teeter Huśtawka +Text Tekst +Text Box Pole tekstowe +Text Box Margins Marginesy pola tekstowego +Text Direction Kierunek tekstu +Text Effects Efekty tekstowe +Text Fill Wypełnienie tekstu +Text Highlight Color Kolor wyróżnienia tekstu +Text Options Opcje tekstu +Text Outline Kontur tekstu +Text Shadow Cień tekstu +Text/Background - Dark 1 Tekst/tło — ciemny 1 +Text/Background - Dark 2 Tekst/tło — ciemny 2 +Text/Background - Light 1 Tekst/tło — jasny 1 +Text/Background - Light 2 Tekst/tło — jasny 2 +Text\nBox Pole\ntekstowe +The spelling check is complete. No issues found. Sprawdzanie pisowni zostało zakończone. Nie znaleziono problemów. +Theme Colors Kolory motywu +Theme Gallery Galeria motywów +Themes Motywy +Thesaurus Tezaurus +Thesaurus (coming soon) Tezaurus (wkrótce) +This Document Ten dokument +Thought Myśl +Thought Bubble: Cloud Dymek myśli: chmurka +Through Black Przez czerń +Tight Ciasne +Timing Chronometraż +Top Góra +Total Row Wiersz sumy +Transition Przejście +Transition Gallery Galeria przejść +Transitions Przejścia +Translate Przetłumacz +Translation needs a language model (not bundled) Tłumaczenie wymaga modelu językowego (niedołączonego) +Transparency Przezroczystość +Trapezoid Trapez +Treemap Mapa drzewa +Triangle Trójkąt +Trigger Wyzwalacz +Trim Przytnij +Trim Media Przytnij multimedia +Turns Zakręty +Type Typ +Type Text Wpisz tekst +Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). Wpisz równanie w formie liniowej (np. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). +Uncover Odkrycie +Underline Podkreślenie +Undo Cofnij +Undo (⌘Z) Cofnij (⌘Z) +Ungroup Rozgrupuj +Up W górę +Use Timings Użyj chronometrażu +Use timings, if present Użyj chronometrażu, jeśli istnieje +Username Nazwa użytkownika +Version Wersja +Version {} Wersja {} +Vert In Pionowo do środka +Vert Out Pionowo na zewnątrz +Vertical Pionowo +Vertical alignment Wyrównanie w pionie +Very Loose Bardzo luźne +Very Tight Bardzo ciasne +Video Wideo +Video from File… Wideo z pliku… +View Widok +Volume Głośność +Vortex Wir +Waterfall Kaskadowy +Wave Fala +Web Page or File Strona internetowa lub plik +Wedge Klin +Weight Grubość +Wheel Koło +Widescreen (13.333x7.5 in) Panoramiczny (13,333x7,5 cala) +Widescreen (16:9) Panoramiczny (16:9) +Width Szerokość +Width (in): Szerokość (cale): +Width: Szerokość: +Width: Szerokość: +Wind Wiatr +Window Okno +Wipe Wytarcie +With Previous Z poprzednią +WordArt WordArt +WordArt style {} Styl WordArt {} +Words Wyrazy +Wrap Text in Shape Zawijaj tekst w kształcie +Wrap text in shape Zawijaj tekst w kształcie +Yellow Żółty +Your changes will be lost if you don't save them. Jeśli nie zapiszesz zmian, zostaną one utracone. +Your name (comments): Twoje imię i nazwisko (komentarze): +Zoom Powiększenie +Zoom In Powiększ +Zoom Out Pomniejsz +Zoom… Powiększenie… +a) b) c) a) b) c) +a. b. c. a. b. c. +dropped file upuszczony plik +i. ii. iii. i. ii. iii. +no presentation open żadna prezentacja nie jest otwarta +no slides brak slajdów +no text insertion point brak punktu wstawiania tekstu +nothing selected nic nie zaznaczono +nothing to redo nie ma czego ponowić +nothing to undo nie ma czego cofnąć +select text or a shape zaznacz tekst lub kształt +select two or more objects zaznacz co najmniej dwa obiekty +size (pt) rozmiar (pkt) +tOGGLE cASE oDWRÓĆ wIELKOŚĆ lITER +unknown interface language nieznany język interfejsu +{ch} Bullet {ch} Punktor +{f} (Body) {f} (tekst podstawowy) +{f} (Headings) {f} (nagłówki) +{name}: DeckCraft can't open this kind of file {name}: DeckCraft nie może otworzyć pliku tego typu +{n} Column{} Liczba kolumn: {n}{} +{r} pt {r} pkt +{r} pt glow Poświata {r} pkt +{w} pt {w} pkt +{} ({} slides) {} (slajdy: {}) +{} Slide Master Wzorzec slajdów {} +{} contributors · {} commits Współtwórcy: {} · commity: {} +{} presentations Prezentacje: {} +{}x{} Table Tabela {}x{} +“{}” is not available yet. Funkcja „{}” nie jest jeszcze dostępna. +−Bin −Przedział +▶ Play All ▶ Odtwórz wszystko +◀ Previous ◀ Poprzedni +✏ on ✏ wł. +Arrow: Circular Strzałka: kolista +Arrow: Circular Counter-clockwise Strzałka: kolista przeciwnie do ruchu wskazówek zegara +Arrow: Circular Two-headed Strzałka: kolista z dwoma grotami +Arrow: Curved Down Strzałka: zakrzywiona w dół +Arrow: Curved Left Strzałka: zakrzywiona w lewo +Arrow: Curved Right Strzałka: zakrzywiona w prawo +Arrow: Curved Up Strzałka: zakrzywiona w górę +Arrow: Swoosh Strzałka: łukowa +Artistic Effects Efekty artystyczne +AutoCorrect Options Opcje Autokorekty +AutoCorrect Options… Opcje Autokorekty… +Bean Fasola +Black and White: 25% Czarno-białe: 25% +Black and White: 50% Czarno-białe: 50% +Black and White: 75% Czarno-białe: 75% +Borders Obramowania +Bounce Left Odbicie w lewo +Bounce Right Odbicie w prawo +Browse for Themes… Przeglądaj motywy… +Callout: Bent Line Objaśnienie: linia łamana +Callout: Bent Line with Accent Bar Objaśnienie: linia łamana z paskiem wyróżnienia +Callout: Bent Line with Border and Accent Bar Objaśnienie: linia łamana z obramowaniem i paskiem wyróżnienia +Callout: Bent Line with No Border Objaśnienie: linia łamana bez obramowania +Callout: Double Bent Line Objaśnienie: linia podwójnie łamana +Callout: Double Bent Line with Accent Bar Objaśnienie: linia podwójnie łamana z paskiem wyróżnienia +Callout: Double Bent Line with Border and Accent Bar Objaśnienie: linia podwójnie łamana z obramowaniem i paskiem wyróżnienia +Callout: Double Bent Line with No Border Objaśnienie: linia podwójnie łamana bez obramowania +Callout: Left-Right Arrow Objaśnienie: strzałka w lewo i w prawo +Callout: Line with Accent Bar Objaśnienie: linia z paskiem wyróżnienia +Callout: Line with Border and Accent Bar Objaśnienie: linia z obramowaniem i paskiem wyróżnienia +Callout: Line with No Border Objaśnienie: linia bez obramowania +Callout: Quad Arrow Objaśnienie: strzałka w cztery strony +Callout: Up-Down Arrow Objaśnienie: strzałka w górę i w dół +Cell Margins Marginesy komórki +Change Colors Zmień kolory +Change Layout Zmień układ +Change Picture… Zmień obraz… +Change Spelling Zmień pisownię +Chart Marker: Cross Lines Znacznik wykresu: linie skrzyżowane +Chart Marker: Plus Lines Znacznik wykresu: plus z linii +Chart Marker: Star Lines Znacznik wykresu: gwiazdka z linii +Commit Composition Zatwierdź wprowadzany tekst +Compare Porównaj +Compare… Porównaj… +Compose Text Wprowadzanie tekstu (IME) +Compress Pictures Kompresuj obrazy +Convert to Shapes Konwertuj na kształty +Convert to SmartArt Konwertuj na grafikę SmartArt +Convert to Text Konwertuj na tekst +Corner Tabs: Quarter Circles Wypustki narożne: ćwiartki koła +Corner Tabs: Squares Wypustki narożne: kwadraty +Corner Tabs: Triangles Wypustki narożne: trójkąty +Flowchart: Offline Storage Schemat blokowy: magazyn offline +Gear: 6 Teeth Koło zębate: 6 zębów +Gear: 9 Teeth Koło zębate: 9 zębów +Line: Rising Linia: wznosząca +Quarter Wedge Klin ćwiartkowy +Ribbon: Curved and Tilted Down Wstęga: wygięta i pochylona w dół +Ribbon: Curved and Tilted Up Wstęga: wygięta i pochylona w górę +Ribbon: Left-Right Fold Wstęga: zagięcia w lewo i w prawo +Trapezoid: Uneven Sides Trapez: nierówne boki +Unlock Odblokuj +Print Drukuj +Info Informacje +Print… Drukuj… +Counterclockwise Przeciwnie do ruchu wskazówek zegara +Crescent Moon Półksiężyc +Curved Square Zaokrąglony kwadrat +Customize Quick Access Toolbar Dostosuj pasek narzędzi Szybki dostęp +Decaying Wave Fala gasnąca +Delete Guide Usuń prowadnicę +Delete Point Usuń punkt +Delete Repeated Word Usuń powtórzony wyraz +Demote Obniż poziom +Diagonal Down Right Po przekątnej w dół w prawo +Diagonal Up Right Po przekątnej w górę w prawo +Diamonds From Bottom Romby od dołu +Diamonds From Left Romby od lewej +Diamonds From Right Romby od prawej +Diamonds From Top Romby od góry +Do not check spelling or grammar Nie sprawdzaj pisowni ani gramatyki +dictionary słownik +Double Left Podwójnie do lewej +Double Right Podwójnie do prawej +Draw Table Rysuj tabelę +Edit Photo Album… Edytuj album fotograficzny… +Edit Points Edytuj punkty +Edit SmartArt Text Edytuj tekst grafiki SmartArt +End Review Zakończ recenzję +Enter a zoom percent Wpisz procent powiększenia +Find Znajdź +Fonts in Presentation Czcionki w prezentacji +Font… Czcionka… +Football Piłka owalna +From Bottom-Left Od lewego dolnego rogu +From Bottom-Right Od prawego dolnego rogu +From Top-Left Od lewego górnego rogu +From Top-Right Od prawego górnego rogu +Grid and Guides Siatka i prowadnice +Grid Options… Opcje siatki… +Handout Placeholders Symbole zastępcze materiałów informacyjnych +Heartbeat Bicie serca +Hexagons From Bottom Sześciokąty od dołu +Hexagons From Left Sześciokąty od lewej +Hexagons From Right Sześciokąty od prawej +Hexagons From Top Sześciokąty od góry +Horizontal Figure 8 Pozioma ósemka +Horizontal In Poziomo do środka +Horizontal Out Poziomo na zewnątrz +Icon Gallery Galeria ikon +Ignore All Ignoruj wszystkie +In with Bounce Do środka z odbiciem +Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes Pismo odręczne na kształt: zamień narysowane linie, prostokąty, owale i trójkąty na kształty +Insert Picture in SmartArt Wstaw obraz do grafiki SmartArt +Insert Picture in SmartArt… Wstaw obraz do grafiki SmartArt… +Insert Synonym Wstaw synonim +Layout Placeholders Symbole zastępcze układu +Loop de Loop Pętla w pętli +Move Guide Przesuń prowadnicę +Move Handle Przesuń uchwyt +Move Point Przesuń punkt +Neutron Neutron +New Nowy +New Window Nowe okno +Next Comment Następny komentarz +No comments. Brak komentarzy. +No icons match. Brak pasujących ikon. +No Recolor Bez zmiany koloru +Notes Placeholders Symbole zastępcze notatek +Office Theme Motyw pakietu Office +Open Otwórz +Out with Bounce Na zewnątrz z odbiciem +Particles In Cząsteczki do środka +Particles Out Cząsteczki na zewnątrz +Paste Special Wklej specjalnie +Paste Special… Wklej specjalnie… +Peanut Orzeszek ziemny +Percent: Procent: +Photo Album… Album fotograficzny… +Pick a design for this slide. Ideas are generated on your device. Wybierz projekt dla tego slajdu. Pomysły są generowane na Twoim urządzeniu. +Picture Border Obramowanie obrazu +Picture Style Gallery Galeria stylów obrazu +Point Type Typ punktu +Points Punkty +Preserve Zachowaj +Preview Animations Podgląd animacji +Previous Comment Poprzedni komentarz +Promote Podwyższ poziom +Quick Access Toolbar Pasek narzędzi Szybki dostęp +Recolor Zmień kolor +Recolor Gallery Galeria zmiany koloru +Remove from Quick Access Toolbar Usuń z paska narzędzi Szybki dostęp +Remove Shape Usuń kształt +Repeated word Powtórzony wyraz +Replace Zamień +Replace Fonts Zamień czcionki +Replace… Zamień… +Rescan Installed Fonts Wyszukaj ponownie zainstalowane czcionki +Reset to Defaults Przywróć domyślne +Reuse Slides… Ponownie użyj slajdów… +Revisions Poprawki +Right-click any ribbon button to add it. Kliknij prawym przyciskiem dowolny przycisk wstążki, aby go dodać. +S Curve 1 Krzywa S 1 +S Curve 2 Krzywa S 2 +Sepia Sepia +Set Transparent Color Ustaw kolor przezroczysty +Set Zoom Ustaw powiększenie +Sine Wave Sinusoida +Single Left Pojedynczo do lewej +Single Right Pojedynczo do prawej +Size and Position… Rozmiar i położenie… +SmartArt Styles Style grafiki SmartArt +Smooth Corners Wygładź narożniki +Smoothly From Left Płynnie od lewej +Smoothly From Right Płynnie od prawej +Sound Dźwięk +Spelling Dictionary Słownik pisowni +Spelling is checked for languages marked (dictionary). Pisownia jest sprawdzana w językach oznaczonych jako (słownik). +Spiral Left Spirala w lewo +Spiral Right Spirala w prawo +Spring Sprężyna +Stairs Down Schody w dół +Start from Beginning Od początku +Starter Templates Szablony startowe +Strips In Paski do środka +Strips Out Paski na zewnątrz +Swoosh Zamach +Table Eraser Gumka do tabel +Tabs… Tabulatory… +Text Pane Okienko tekstu +Text Transform Przekształcenie tekstu +Text Transparency Przezroczystość tekstu +Themes and presentations Motywy i prezentacje +Through Black From Left Przez czerń od lewej +Through Black From Right Przez czerń od prawej +Variant Gallery Galeria wariantów +Variants Warianty +Vertical Figure 8 Pionowa ósemka +Vertical In Pionowo do środka +Vertical Out Pionowo na zewnątrz +Washout Rozjaśnienie +Zigzag Zygzak +Zoom and Rotate Powiększenie i obrót +Zoom to Powiększ do +■ Stop ■ Zatrzymaj +circle okrąg +diamond romb +plus plus +▶ Play From ▶ Odtwórz od zaznaczonego +Pattern Deseń +List Slides to Reuse Pokaż slajdy do ponownego użycia +Delete Segment Usuń segment +Segment Type Typ segmentu +Open Path Otwórz ścieżkę +Close Path Zamknij ścieżkę +Reject Revision Odrzuć poprawkę +Duration Czas trwania +Delay Opóźnienie +Delete Surrounding Text Usuń otaczający tekst +{} options {} — opcje +Missing slide title Brak tytułu slajdu +Missing alternative text Brak tekstu alternatywnego +Hard-to-read text contrast ({}:1) Słabo czytelny kontrast tekstu ({}:1) +Apply to: Zastosuj do: +Selected text Zaznaczonego tekstu +Whole presentation Całej prezentacji +Measurement units: Jednostki miary: +Inches Cale +Centimeters Centymetry +Select Slide Range Zaznacz zakres slajdów +Language for All Text Język całego tekstu +Mark all text in the presentation as: Oznacz cały tekst prezentacji jako: +Check for Updates… Sprawdź aktualizacje… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv new file mode 100644 index 000000000..e33e3408d --- /dev/null +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -0,0 +1,1488 @@ +# 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 +Бін + (not checked) (не перевіряється) +100% Stacked Bar Нормована лінійчата з накопиченням +100% Stacked Column Нормована стовпчаста з накопиченням +35mm Slides Слайди 35 мм +4 Point Star 4-кутна зірка +5 Point Star 5-кутна зірка +6 Point Star 6-кутна зірка +8 Point Star 8-кутна зірка +@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 +Accept Revision Прийняти виправлення +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 Кнопки дій +Action Settings Параметри дії +Add Animation Додати анімацію +Add Font Додати шрифт +Add Guide Додати напрямну +Add Horizontal Guide Додати горизонтальну напрямну +Add Point Додати точку +Add Section Додати розділ +Add Shape Додати фігуру +Add to Dictionary Додати до словника +Add to Quick Access Toolbar Додати до панелі швидкого доступу +Add Vertical Guide Додати вертикальну напрямну +Add\nAnimation Додати анімацію +Address: Адреса: +Adjust Shape Змінити фігуру +After Previous Після попереднього +After: Після: +Airplane Літак +Align Вирівняти +Align Bottom Вирівняти за нижнім краєм +Align Cell Text Вирівняти текст у клітинці +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 Design Idea Застосувати ідею оформлення +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 At Колір у точці +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 Вміст +Contract Згорнути +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 Визирання +Peek Out Ховання +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 Проста біла рамка +Sink Down Опускання +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 ✏ увімкнено +Arrow: Circular Стрілка: Кругова +Arrow: Circular Counter-clockwise Стрілка: Кругова проти годинникової стрілки +Arrow: Circular Two-headed Стрілка: Кругова двонаправлена +Arrow: Curved Down Стрілка: Вигнута вниз +Arrow: Curved Left Стрілка: Вигнута ліворуч +Arrow: Curved Right Стрілка: Вигнута праворуч +Arrow: Curved Up Стрілка: Вигнута вгору +Arrow: Swoosh Стрілка: Дугова +Artistic Effects Художні ефекти +AutoCorrect Options Параметри автовиправлення +AutoCorrect Options… Параметри автовиправлення… +Bean Квасоля +Black and White: 25% Чорно-білий: 25% +Black and White: 50% Чорно-білий: 50% +Black and White: 75% Чорно-білий: 75% +Borders Межі +Bounce Left Відскок ліворуч +Bounce Right Відскок праворуч +Browse for Themes… Огляд тем… +Callout: Bent Line Виноска: Ламана лінія +Callout: Bent Line with Accent Bar Виноска: Ламана лінія зі смугою акценту +Callout: Bent Line with Border and Accent Bar Виноска: Ламана лінія з рамкою і смугою акценту +Callout: Bent Line with No Border Виноска: Ламана лінія без рамки +Callout: Double Bent Line Виноска: Двічі ламана лінія +Callout: Double Bent Line with Accent Bar Виноска: Двічі ламана лінія зі смугою акценту +Callout: Double Bent Line with Border and Accent Bar Виноска: Двічі ламана лінія з рамкою і смугою акценту +Callout: Double Bent Line with No Border Виноска: Двічі ламана лінія без рамки +Callout: Left-Right Arrow Виноска: Стрілка ліворуч-праворуч +Callout: Line with Accent Bar Виноска: Лінія зі смугою акценту +Callout: Line with Border and Accent Bar Виноска: Лінія з рамкою і смугою акценту +Callout: Line with No Border Виноска: Лінія без рамки +Callout: Quad Arrow Виноска: Стрілка в чотири боки +Callout: Up-Down Arrow Виноска: Стрілка вгору-вниз +Cell Margins Поля клітинки +Change Colors Змінити кольори +Change Layout Змінити макет +Change Picture… Змінити зображення… +Change Spelling Змінити написання +Chart Marker: Cross Lines Маркер діаграми: Перехресні лінії +Chart Marker: Plus Lines Маркер діаграми: Плюс із ліній +Chart Marker: Star Lines Маркер діаграми: Зірка з ліній +Commit Composition Підтвердити введений текст +Compare Порівняти +Compare… Порівняти… +Compose Text Введення тексту (IME) +Compress Pictures Стиснути зображення +Convert to Shapes Перетворити на фігури +Convert to SmartArt Перетворити на SmartArt +Convert to Text Перетворити на текст +Corner Tabs: Quarter Circles Кутові вкладки: Чверті кола +Corner Tabs: Squares Кутові вкладки: Квадрати +Corner Tabs: Triangles Кутові вкладки: Трикутники +Flowchart: Offline Storage Блок-схема: Автономне сховище +Gear: 6 Teeth Шестерня: 6 зубців +Gear: 9 Teeth Шестерня: 9 зубців +Line: Rising Лінія: Висхідна +Quarter Wedge Клин-чверть +Ribbon: Curved and Tilted Down Стрічка: Вигнута, нахил униз +Ribbon: Curved and Tilted Up Стрічка: Вигнута, нахил угору +Ribbon: Left-Right Fold Стрічка: Згини ліворуч-праворуч +Trapezoid: Uneven Sides Трапеція: Нерівні сторони +Unlock Розблокувати +Print Друк +Info Відомості +Print… Друк… +Counterclockwise Проти годинникової стрілки +Crescent Moon Півмісяць +Curved Square Заокруглений квадрат +Customize Quick Access Toolbar Налаштувати панель швидкого доступу +Decaying Wave Затухаюча хвиля +Delete Guide Видалити напрямну +Delete Point Видалити точку +Delete Repeated Word Видалити повторене слово +Demote Понизити рівень +Diagonal Down Right Діагональ униз праворуч +Diagonal Up Right Діагональ угору праворуч +Diamonds From Bottom Ромби знизу +Diamonds From Left Ромби зліва +Diamonds From Right Ромби справа +Diamonds From Top Ромби згори +Do not check spelling or grammar Не перевіряти правопис і граматику +dictionary словник +Double Left Подвійно ліворуч +Double Right Подвійно праворуч +Draw Table Намалювати таблицю +Edit Photo Album… Змінити фотоальбом… +Edit Points Змінити точки +Edit SmartArt Text Змінити текст SmartArt +End Review Завершити рецензування +Enter a zoom percent Введіть відсоток масштабу +Find Знайти +Fonts in Presentation Шрифти в презентації +Font… Шрифт… +Football Овальний м'яч +From Bottom-Left Із нижнього лівого кута +From Bottom-Right Із нижнього правого кута +From Top-Left Із верхнього лівого кута +From Top-Right Із верхнього правого кута +Grid and Guides Сітка й напрямні +Grid Options… Параметри сітки… +Handout Placeholders Заповнювачі роздаткових матеріалів +Heartbeat Серцебиття +Hexagons From Bottom Шестикутники знизу +Hexagons From Left Шестикутники зліва +Hexagons From Right Шестикутники справа +Hexagons From Top Шестикутники згори +Horizontal Figure 8 Горизонтальна вісімка +Horizontal In Горизонтально всередину +Horizontal Out Горизонтально назовні +Icon Gallery Колекція піктограм +Ignore All Пропустити всі +In with Bounce Усередину з відскоком +Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes Рукописне введення у фігуру: перетворити намальовані лінії, прямокутники, овали й трикутники на фігури +Insert Picture in SmartArt Вставити зображення в SmartArt +Insert Picture in SmartArt… Вставити зображення в SmartArt… +Insert Synonym Вставити синонім +Layout Placeholders Заповнювачі макета +Loop de Loop Петля в петлі +Move Guide Перемістити напрямну +Move Handle Перемістити маркер +Move Point Перемістити точку +Neutron Нейтрон +New Створити +New Window Нове вікно +Next Comment Наступний коментар +No comments. Немає коментарів. +No icons match. Немає відповідних піктограм. +No Recolor Без перефарбування +Notes Placeholders Заповнювачі нотаток +Office Theme Тема Office +Open Відкрити +Out with Bounce Назовні з відскоком +Particles In Частинки всередину +Particles Out Частинки назовні +Paste Special Спеціальне вставлення +Paste Special… Спеціальне вставлення… +Peanut Арахіс +Percent: Відсоток: +Photo Album… Фотоальбом… +Pick a design for this slide. Ideas are generated on your device. Виберіть оформлення для цього слайда. Ідеї створюються на вашому пристрої. +Picture Border Межа зображення +Picture Style Gallery Колекція стилів зображення +Point Type Тип точки +Points Точки +Preserve Зберегти +Preview Animations Попередній перегляд анімації +Previous Comment Попередній коментар +Promote Підвищити рівень +Quick Access Toolbar Панель швидкого доступу +Recolor Перефарбувати +Recolor Gallery Колекція перефарбування +Remove from Quick Access Toolbar Видалити з панелі швидкого доступу +Remove Shape Видалити фігуру +Repeated word Повторене слово +Replace Замінити +Replace Fonts Замінити шрифти +Replace… Замінити… +Rescan Installed Fonts Повторно знайти встановлені шрифти +Reset to Defaults Відновити стандартні +Reuse Slides… Повторно використати слайди… +Revisions Виправлення +Right-click any ribbon button to add it. Клацніть правою кнопкою будь-яку кнопку стрічки, щоб додати її. +S Curve 1 S-крива 1 +S Curve 2 S-крива 2 +Sepia Сепія +Set Transparent Color Установити прозорий колір +Set Zoom Установити масштаб +Sine Wave Синусоїда +Single Left Одинарно ліворуч +Single Right Одинарно праворуч +Size and Position… Розмір і розташування… +SmartArt Styles Стилі SmartArt +Smooth Corners Згладити кути +Smoothly From Left Плавно зліва +Smoothly From Right Плавно справа +Sound Звук +Spelling Dictionary Орфографічний словник +Spelling is checked for languages marked (dictionary). Правопис перевіряється для мов, позначених (словник). +Spiral Left Спіраль ліворуч +Spiral Right Спіраль праворуч +Spring Пружина +Stairs Down Сходи вниз +Start from Beginning З початку +Starter Templates Початкові шаблони +Strips In Смуги всередину +Strips Out Смуги назовні +Swoosh Змах +Table Eraser Гумка для таблиці +Tabs… Табуляція… +Text Pane Область тексту +Text Transform Перетворення тексту +Text Transparency Прозорість тексту +Themes and presentations Теми й презентації +Through Black From Left Через чорний зліва +Through Black From Right Через чорний справа +Variant Gallery Колекція варіантів +Variants Варіанти +Vertical Figure 8 Вертикальна вісімка +Vertical In Вертикально всередину +Vertical Out Вертикально назовні +Washout Вицвітання +Zigzag Зигзаг +Zoom and Rotate Масштаб і обертання +Zoom to Масштабувати до +■ Stop ■ Зупинити +circle коло +diamond ромб +plus плюс +▶ Play From ▶ Відтворити з вибраного +Pattern Візерунок +List Slides to Reuse Показати слайди для повторного використання +Delete Segment Видалити сегмент +Segment Type Тип сегмента +Open Path Розімкнути контур +Close Path Замкнути контур +Reject Revision Відхилити виправлення +Duration Тривалість +Delay Затримка +Delete Surrounding Text Видалити навколишній текст +{} options {} — параметри +Missing slide title Немає заголовка слайда +Missing alternative text Немає замінного тексту +Hard-to-read text contrast ({}:1) Важкочитний контраст тексту ({}:1) +Apply to: Застосувати до: +Selected text Виділеного тексту +Whole presentation Усієї презентації +Measurement units: Одиниці вимірювання: +Inches Дюйми +Centimeters Сантиметри +Select Slide Range Вибрати діапазон слайдів +Language for All Text Мова для всього тексту +Mark all text in the presentation as: Позначити весь текст презентації як: +Check for Updates… Перевірити оновлення… diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c5a7a681..c3497895b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -16,6 +16,7 @@ pub mod dialogs; pub mod doc_window; pub mod eyedropper; pub mod fillui; +pub mod i18n; pub mod icons; pub mod media; pub mod menus; @@ -234,7 +235,7 @@ fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'stati FilePurpose::Open => open_command(name), FilePurpose::Infer => infer_command(name, bytes), }; - cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file")) + cmd.ok_or_else(|| crate::i18n::format("{name}: DeckCraft can't open this kind of file", &[name.to_string()])) } /// How long a transient status message stays visible. @@ -243,6 +244,8 @@ const STATUS_LINGER_MS: f64 = 6000.0; /// 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. @@ -280,6 +283,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, @@ -326,6 +330,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, @@ -444,6 +449,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.checkUpdates", "Check for Updates…", None, "{}"), ("app.palette", "Command Palette", Some("Cmd+Shift+P"), "{}"), ("app.preferences", "Preferences…", Some("Cmd+,"), "{}"), + ("app.language", "Interface Language", None, "{language: system|en|uk|pl}"), ("app.openDialog", "Open…", None, "{purpose?}"), ("app.insertPictureDialog", "Picture from File…", None, "{}"), ("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"), @@ -885,7 +891,9 @@ 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())); } /// Turn on the automatic check for a newer release at start-up (desktop shell only; tests and @@ -954,6 +962,7 @@ impl SlideApp { /// 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 { @@ -1125,7 +1134,7 @@ impl SlideApp { } return match self.run("file.export", params) { Ok(_) => { - self.set_status(format!("Exported {path}")); + self.set_status(crate::i18n::format("Exported {path}", std::slice::from_ref(&path))); true } Err(_) => false, @@ -1376,6 +1385,17 @@ impl SlideApp { return Err("updates are handled by the browser: reload the page".into()); } "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, + "pl" => i18n::Language::Pl, + _ => 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"), @@ -1731,7 +1751,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); } @@ -2026,6 +2046,7 @@ impl SlideApp { log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id()); return; } + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); self.tick_hidden_show(ctx, web_time::Instant::now()); // `raw_input_hook` runs before `App::logic`. In a full pass egui has already built // `PointerState` and `keys_down` from every injected event, so retracting the events after @@ -2111,7 +2132,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()])); } } if self.session.documents().iter().any(|d| d.is_dirty()) { @@ -3284,6 +3305,7 @@ impl SlideApp { /// no-document paths, reaches the same outer reset observation and finalizer below. No early /// route may publish or inspect IME output on its own. pub fn ui(&mut self, ui: &mut egui::Ui) { + i18n::set_current(self.ui.interface_language.resolve(self.services.system_locale.as_deref())); let ctx = ui.ctx().clone(); if !Self::root_viewport_admitted(&ctx) { log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id()); @@ -4190,6 +4212,31 @@ mod tests { /// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog /// changed while it was open; Done keeps it. + #[test] + fn preferences_cancel_restores_the_interface_language() { + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SlideApp::new(Session::with_new(), Services::default()), + ); + harness.run_steps(2); + let before = harness.state().ui.interface_language; + harness.state_mut().run("app.preferences", json!({})).unwrap(); + harness.run_steps(2); + // The language list applies a choice at once, as the other Preferences controls do. + harness.state_mut().run("app.language", json!({"language": "pl"})).unwrap(); + harness.run_steps(2); + assert_eq!(harness.state().ui.interface_language, i18n::Language::Pl); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + let s = harness.state_mut(); + assert!(s.dialog.is_none(), "Esc closes Preferences"); + assert_eq!(s.ui.interface_language, before, "Esc restores the interface language"); + let _ = s.run("app.language", json!({"language": "en"})); + } + #[test] fn preferences_cancel_restores_the_settings_done_keeps_them() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 956f2d9b9..ed418af7a 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -521,7 +521,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)); @@ -556,7 +556,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 f78e25267..fe1eb6ad5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -86,8 +86,13 @@ fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, S .into_iter() // Commands that need a path or other value fail when the palette runs them with `{}`. .filter(|c| c.enabled && !crate::UI_COMMANDS.iter().any(|u| u.1 == c.label) && !deckcraft_engine::cmd::needs_arguments(c.params)) - .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) - .chain(crate::UI_COMMANDS.iter().filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3)).map(|c| (c.0.to_string(), c.1.to_string(), c.2))) + .map(|c| (c.id.to_string(), crate::i18n::tr(c.label).to_string(), c.shortcut)) + .chain( + crate::UI_COMMANDS + .iter() + .filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3)) + .map(|c| (c.0.to_string(), 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)); @@ -106,7 +111,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 items = palette_items(&app.session, &q); @@ -133,7 +143,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 74482f930..5cb3195f3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -34,7 +34,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() { @@ -191,7 +191,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| { @@ -209,7 +209,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); @@ -236,7 +236,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"), @@ -246,7 +246,7 @@ 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})); } } @@ -272,10 +272,10 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { fx_slider(app, ui, ("shadow", sh.id.0, "d"), "Distance", sw.dist, 0.0..=200.0, " pt", "shadowProps", "dist", 1.0); } ui.add_space(6.0); - ui.label(egui::RichText::new("Reflection").font(theme::bold(13.0))); + ui.label(egui::RichText::new(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})); } } @@ -298,7 +298,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { fx_slider(app, ui, ("refl", sh.id.0, "b"), "Blur", r.blur, 0.0..=100.0, " pt", "reflectionProps", "blur", 1.0); } ui.add_space(6.0); - ui.label(egui::RichText::new("Glow").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Glow")).font(theme::bold(13.0))); let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); if let Some(g) = &fx.glow { @@ -316,14 +316,14 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { 0.01, ); } - 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})); } let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); three_d_options(app, ui, sh.id.0, raw3d.as_deref()); - if ui.button("Reset Effects").clicked() { + if ui.button(crate::i18n::tr("Reset Effects")).clicked() { run(app, "shape.effects", json!({"reset": true})); } } @@ -335,8 +335,8 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let mut fields = |ui: &mut Ui, label: &str, id: &str, v: f64, key: &str, cmd: &str, angle: bool| { let (unit, suffix) = if angle { (1.0, "°") } else { (len.points_per_unit(), len.suffix()) }; ui.horizontal(|ui| { - ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - if let Some(nv) = widgets::spinner(ui, id, label, v / unit, 0.1, suffix, 70.0) { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::tr(label))); + if let Some(nv) = widgets::spinner(ui, id, crate::i18n::tr(label), v / unit, 0.1, suffix, 70.0) { let mut p = json!({key: nv * unit}); if cmd == "shape.resize" && locked { p["lockAspect"] = json!(true); @@ -345,7 +345,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } }); }; - ui.label(egui::RichText::new("Size").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Size")).font(theme::bold(13.0))); fields(ui, "Height", "fh", x.h, "h", "shape.resize", false); fields(ui, "Width", "fw", x.w, "w", "shape.resize", false); fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true); @@ -353,11 +353,11 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(lock_id, lock)); } ui.add_space(6.0); - ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Position")).font(theme::bold(13.0))); fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false); fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false); ui.add_space(6.0); - ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Alt Text")).font(theme::bold(13.0))); let id = ui.scope_id().with(("alt", uid, sh.id.0)); let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); let mut alt = widgets::text_draft(stored, &sh.descr); @@ -368,35 +368,35 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ui.data_mut(|d| d.insert_temp(id, (sh.descr.clone(), alt))); } "text" => { - ui.label(egui::RichText::new("Text Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::i18n::tr("Text Fill")).font(theme::bold(13.0))); color_button(app, ui, "Color", None, |c| match c { Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), None => ("format.color".into(), json!({"color": "tx1"})), }); text_transparency_slider(app, ui, &sh); ui.add_space(6.0); - ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); + ui.label(egui::RichText::new(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)), @@ -405,14 +405,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)); - if let Some(nv) = widgets::spinner(ui, &format!("m{k}"), l, v, 1.0, " pt", 60.0) { + 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}"), crate::i18n::tr(l), 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})); @@ -426,29 +426,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", @@ -466,7 +466,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ("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"})), @@ -497,7 +497,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { if !transparency { return; } - let label = ui.label("Transparency"); + let label = ui.label(crate::i18n::tr("Transparency")); // Seed from this shape. A shared temp defaulted to 0, so a shape that was // already 40% transparent showed as opaque, and leaving the slider wrote 0. let seed = match &sh.fill { @@ -525,7 +525,7 @@ 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)})); } @@ -536,15 +536,15 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.fill", json!({"pattern": crate::fillui::pattern_json(&np)})); } ui.add_space(8.0); - ui.label(egui::RichText::new("Line").font(theme::bold(13.0))); + ui.label(egui::RichText::new(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 { @@ -552,16 +552,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", "Width", w, 0.25, " pt", 60.0) { run(app, "shape.line", json!({"width": v.max(0.0)})); } }); ui.horizontal_wrapped(|ui| { - ui.label("Dash"); + ui.label(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})); } } @@ -648,7 +648,7 @@ fn fx_slider( scale: f64, ) { ui.horizontal(|ui| { - let label = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let label = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::tr(label))); // Hold the value while dragging; otherwise follow the shape. let sid = ui.scope_id().with(id); let mut v: f64 = ui.data_mut(|d| d.get_temp(sid)).unwrap_or(value); @@ -845,7 +845,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"), @@ -853,7 +853,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" => { @@ -879,7 +879,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) { @@ -905,7 +905,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { } } Some(Fill::Picture(pf)) => { - 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)) @@ -919,13 +919,13 @@ 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); let picture = matches!(fill, Some(Fill::Picture(_))); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if ui.add_enabled(current.is_some() || picture, egui::Button::new(crate::i18n::tr("Apply to All"))).clicked() { if let Some(Value::Object(mut o)) = current.clone() { o.insert("all".into(), json!(true)); run(app, "design.background", Value::Object(o)); @@ -933,7 +933,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { run(app, "design.background", json!({"copy": true})); } } - if ui.button("Reset Background").clicked() { + if ui.button(crate::i18n::tr("Reset Background")).clicked() { run(app, "design.background", json!({"reset": true})); } }); @@ -1049,7 +1049,7 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { (false, Some(_)) => "▶ Play From", (false, None) => "▶ Play All", }; - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { let p = match (playing, from) { (false, Some(i)) => json!({"on": true, "from": i}), _ => json!({"on": !playing}), @@ -1059,7 +1059,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 bars = animtimeline::bars(anims.len(), &effects); @@ -1071,7 +1073,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() { @@ -1166,23 +1172,23 @@ 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() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); ui.close(); } - if ui.button("Move Down").clicked() { + if ui.button(crate::i18n::tr("Move Down")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.move", json!({"index": i, "to": i + 1})); ui.close(); } - if ui.button("Remove").clicked() { + if ui.button(crate::i18n::tr("Remove")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.remove", json!({"index": i})); ui.close(); @@ -1272,7 +1278,7 @@ fn revisions(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(); @@ -1282,7 +1288,7 @@ fn comments(app: &mut SlideApp, ui: &mut Ui) { .map(|st| st.doc.slides.iter().enumerate().filter(|(_, sl)| !sl.comments.is_empty()).map(|(i, sl)| (i, sl.comments.clone())).collect()) .unwrap_or_default(); if threads.is_empty() { - ui.label(egui::RichText::new("No comments.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("No comments.")).color(t.text_dim)); return; } let focus = app.ui.comment_focus; @@ -1339,7 +1345,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: ui.horizontal(|ui| { let id = ui.scope_id().with(("reply", slide, index)); let mut txt: String = ui.data_mut(|d| d.get_temp(id).unwrap_or_default()); - let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text("Reply…").desired_width(ui.available_width() - 70.0)); + let r = ui.add(egui::TextEdit::singleline(&mut txt).hint_text(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() { focus_comment(app, slide, index); run(app, "comment.reply", json!({"index": index, "text": txt})); @@ -1384,7 +1390,7 @@ struct IdeaThumbs { /// `design.applyIdea` or `design.theme`. fn designer(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new("Pick a design for this slide. Ideas are generated on your device.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Pick a design for this slide. Ideas are generated on your device.")).color(t.text_dim)); ui.add_space(6.0); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); diff --git a/crates/ui-egui/src/qat.rs b/crates/ui-egui/src/qat.rs index a0b77dbd7..fa8833ac9 100644 --- a/crates/ui-egui/src/qat.rs +++ b/crates/ui-egui/src/qat.rs @@ -104,9 +104,10 @@ fn run(app: &mut SlideApp, q: &QatItem) { fn tip(q: &QatItem) -> String { let sc = deckcraft_engine::find_command(&q.id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == q.id).and_then(|c| c.2)); + let label = crate::i18n::tr(&q.label); match sc { - Some(s) => format!("{} ({})", q.label, crate::ribbon::pretty_shortcut(s)), - None => q.label.clone(), + Some(s) => format!("{} ({})", label, crate::ribbon::pretty_shortcut(s)), + None => label.to_string(), } } @@ -125,7 +126,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { run(app, q); } r.context_menu(|ui| { - if ui.button("Remove from Quick Access Toolbar").clicked() { + if ui.button(crate::i18n::tr("Remove from Quick Access Toolbar")).clicked() { let _ = command(app, &json!({"remove": q.id, "params": q.params})); ui.close(); } @@ -133,10 +134,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, "Customize Quick Access Toolbar", true, false); egui::Popup::menu(&r).show(|ui| { - ui.label(egui::RichText::new("Customize Quick Access Toolbar").strong()); + ui.label(egui::RichText::new(crate::i18n::tr("Customize Quick Access Toolbar")).strong()); for c in common() { let mut on = app.ui.qat.iter().any(|q| q.same(&c.id, &Value::Null)); - if ui.checkbox(&mut on, &c.label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(&c.label)).changed() { let key = if on { "add" } else { "remove" }; let _ = command(app, &json!({key: c.id})); } @@ -146,14 +147,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); for q in extra { let mut on = true; - if ui.checkbox(&mut on, &q.label).changed() { + if ui.checkbox(&mut on, crate::i18n::tr(&q.label)).changed() { let _ = command(app, &json!({"remove": q.id, "params": q.params})); } } } ui.separator(); - ui.label(egui::RichText::new("Right-click any ribbon button to add it.").weak()); - if ui.button("Reset to Defaults").clicked() { + ui.label(egui::RichText::new(crate::i18n::tr("Right-click any ribbon button to add it.")).weak()); + if ui.button(crate::i18n::tr("Reset to Defaults")).clicked() { let _ = command(app, &json!({"reset": true})); ui.close(); } @@ -165,7 +166,7 @@ pub fn ribbon_menu(app: &mut SlideApp, r: &egui::Response, icon: Icon, label: &s r.context_menu(|ui| { let has = app.ui.qat.iter().any(|q| q.same(id, p)); let text = if has { "Remove from Quick Access Toolbar" } else { "Add to Quick Access Toolbar" }; - if ui.button(text).clicked() { + if ui.button(crate::i18n::tr(text)).clicked() { let label = label.replace('\n', " "); let key = if has { "remove" } else { "add" }; let _ = command(app, &json!({key: id, "params": p, "label": label, "icon": icon.name()})); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cedd34c4e..564070ef7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -79,8 +79,11 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) { let has_doc = app.session.active().is_some(); let dirty = app.session.active().is_some_and(|d| d.is_dirty()); crate::qat::show(app, &mut child); - let title = - app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); + 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( @@ -93,7 +96,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(control_h / 2.0) .min_size(vec2(84.0, control_h)), @@ -187,18 +190,23 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { } 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.scope_id().with(("tab", name)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let active = app.ui.tab == name; if resp.hovered() && !active { ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); } let col = if contextual { t.contextual } else { t.text }; let f = if active { theme::bold(13.0) } else { font }; - ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col); + ui.painter().text(r.center(), Align2::CENTER_CENTER, 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); @@ -365,6 +373,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)), @@ -397,21 +406,21 @@ 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 Special…").clicked() { + if ui.button(crate::i18n::tr("Paste Special…")).clicked() { run(app, "app.pasteSpecial", json!({})); } - if ui.button("Paste Formatting").clicked() { + if ui.button(crate::i18n::tr("Paste Formatting")).clicked() { run(app, "format.painterApply", json!({})); } }); @@ -421,7 +430,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() { @@ -487,7 +497,7 @@ 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 { @@ -511,15 +521,15 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) { fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { layout_menu(app, ui, "slide.new"); ui.separator(); - if ui.button("Duplicate Selected Slides").clicked() { + if ui.button(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")); } let desktop = app.services.pick_open.is_some(); for (label, keep) in [("Reuse Slides…", false), ("Reuse Slides (Keep Source Formatting)…", true)] { - let r = ui.add_enabled(desktop, egui::Button::new(label)); + let r = ui.add_enabled(desktop, egui::Button::new(crate::i18n::tr(label))); if r.clicked() { run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); } @@ -529,10 +539,10 @@ fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) { /// The Section split button's menu. fn section_menu(app: &mut SlideApp, ui: &mut Ui) { - if ui.button("Add Section").clicked() { + if ui.button(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!({})); } } @@ -561,9 +571,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() }; @@ -608,7 +618,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})); } } @@ -622,7 +632,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})); } } @@ -787,17 +797,17 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 1.0; let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); - let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); - if m.on_hover_text("Bullets").clicked() { + let (m, a) = small_button_named(ui, Icon::Bullets, "", crate::i18n::tr("Bullets"), en, b("bullets"), true); + 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})); } } @@ -809,13 +819,13 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } }); } - let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); - if m.on_hover_text("Numbering").clicked() { + let (m, a) = small_button_named(ui, Icon::Numbering, "", crate::i18n::tr("Numbering"), en, b("numbering"), true); + 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 [ @@ -827,7 +837,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})); } } @@ -848,17 +858,25 @@ 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() { run(app, "format.paragraphDialog", json!({})); } - if ui.button("Tabs…").clicked() { + if ui.button(crate::i18n::tr("Tabs…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("tabs")); } }); 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" }, + ["стовпець", "стовпці", "стовпців"], + ["kolumna", "kolumny", "kolumn"], + )) + .clicked() + { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } @@ -883,7 +901,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})); } } @@ -891,7 +909,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})); } } @@ -913,7 +931,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) { @@ -922,7 +940,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); } } @@ -933,7 +951,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); } } @@ -1008,7 +1026,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() }); @@ -1066,15 +1084,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"), @@ -1084,13 +1102,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"), @@ -1099,7 +1117,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(); } @@ -1108,7 +1126,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"), @@ -1117,36 +1135,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", @@ -1167,7 +1185,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"})); } } @@ -1196,7 +1214,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); } @@ -1211,7 +1229,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), @@ -1221,20 +1239,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(); } @@ -1332,7 +1350,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(); } @@ -1346,7 +1364,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({})); photo_album_button(app, ui); let r = big_button(ui, Icon::Screenshot, "Screenshot", false); - let _ = r.on_hover_text("Screenshot (not available yet)"); + let _ = r.on_hover_text(crate::i18n::tr("Screenshot (not available yet)")); }); group(ui, |ui| { let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); @@ -1365,7 +1383,7 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); } if let Some(k) = k - && ui.button(k.label()).clicked() + && ui.button(crate::i18n::tr(k.label())).clicked() { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } @@ -1379,7 +1397,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})); } } @@ -1399,7 +1417,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})); } } @@ -1423,8 +1441,8 @@ pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> { let id = ui.scope_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; @@ -1456,7 +1474,7 @@ fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_en } } for cat in cats { - ui.label(egui::RichText::new(cat).small().strong()); + ui.label(egui::RichText::new(crate::i18n::tr(cat)).small().strong()); ui.horizontal_wrapped(|ui| { for icon in icons.iter().filter(|i| i.category == cat) { icon_cell(app, ui, icon, color, hover); @@ -1476,7 +1494,7 @@ fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_eng } }); if n == 0 { - ui.label("No icons match."); + ui.label(crate::i18n::tr("No icons match.")); } } @@ -1581,14 +1599,14 @@ fn draw(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. }))); let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink); - if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() { + if r.on_hover_text(crate::i18n::tr("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes")).clicked() { app.session.set_tool(ToolKind::Select); run(app, "draw.inkToShape", json!({})); } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); - let _ = r.on_hover_text("Ink to Text (coming soon)"); + let _ = r.on_hover_text(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)")); }); } @@ -1715,7 +1733,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(); } @@ -1724,7 +1742,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})); } }); @@ -1732,13 +1750,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")); } }); @@ -1753,7 +1771,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)); @@ -1795,7 +1813,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()); @@ -1818,7 +1836,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !rows.is_empty()); egui::Popup::menu(&r).show(|ui| { for &(o, on) in &rows { - if ui.selectable_label(on, deckcraft_model::anim::transition_option_label(&kind, o)).clicked() { + if ui.selectable_label(on, crate::i18n::tr(&deckcraft_model::anim::transition_option_label(&kind, o))).clicked() { run(app, "transition.options", json!({"option": o})); ui.close(); } @@ -1870,7 +1888,7 @@ fn transitions(app: &mut SlideApp, ui: &mut 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", "Duration", d, 0.25, "", 46.0) { run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); @@ -1878,14 +1896,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", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { @@ -1897,12 +1915,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(), @@ -1989,7 +2012,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 ---------- @@ -2052,7 +2075,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()); @@ -2074,7 +2097,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::EffectOptions, "Effect\nOptions", !effect_rows.is_empty()); egui::Popup::menu(&r).show(|ui| { for &(o, on) in &effect_rows { - if ui.selectable_label(on, option_label(o)).clicked() { + if ui.selectable_label(on, crate::i18n::tr(&option_label(o))).clicked() { run(app, "animation.options", json!({"option": o})); ui.close(); } @@ -2082,7 +2105,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); for &(id, on) in &build_rows { let label = if id == "byParagraph" { "By Paragraph" } else { "As One Object" }; - if ui.selectable_label(on, label).clicked() { + if ui.selectable_label(on, crate::i18n::tr(label)).clicked() { run(app, "animation.options", json!({"textBuild": id})); ui.close(); } @@ -2113,13 +2136,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})); } } @@ -2141,15 +2164,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(); } @@ -2157,18 +2180,30 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { }); }); ui.horizontal(|ui| { - ui.label("Duration:"); - if let Some(v) = - widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0) - { + ui.label(crate::i18n::tr("Duration:")); + if let Some(v) = widgets::spinner( + ui, + "adur", + crate::i18n::tr("Duration"), + cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), + 0.25, + "", + 46.0, + ) { run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); } }); ui.horizontal(|ui| { - ui.label("Delay:"); - if let Some(v) = - widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) - { + ui.label(crate::i18n::tr("Delay:")); + if let Some(v) = widgets::spinner( + ui, + "adelay", + crate::i18n::tr("Delay"), + cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), + 0.25, + "", + 46.0, + ) { run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64})); } }); @@ -2185,7 +2220,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 ---------- @@ -2220,20 +2261,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); - if ui.checkbox(&mut mc, "Show Media Controls").changed() { + if ui.checkbox(&mut mc, crate::i18n::tr("Show Media Controls")).changed() { run(app, "show.setup", json!({"showMediaControls": mc})); } }); @@ -2243,7 +2284,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(app.show_start_index(), false); if let Some(s) = app.show.as_mut() { @@ -2292,7 +2333,7 @@ fn review(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let r = big_button(ui, Icon::Translate, "Translate", false); - let _ = r.on_hover_text("Translation needs a language model (not bundled)"); + let _ = r.on_hover_text(crate::i18n::tr("Translation needs a language model (not bundled)")); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); group(ui, |ui| { @@ -2355,15 +2396,15 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { let mut r = app.ui.ruler; - if ui.checkbox(&mut r, "Ruler").changed() { + if ui.checkbox(&mut r, 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; } }); @@ -2533,7 +2574,7 @@ 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})); } @@ -2546,10 +2587,10 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { smooth_corners_row(app, ui); }); let can_merge = enabled(app, "shape.merge"); - let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); + let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text(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(); } @@ -2576,7 +2617,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(); } @@ -2620,7 +2661,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).width(312.0).show(|ui| { - if ui.button("Shadow").clicked() { + if ui.button(crate::i18n::tr("Shadow")).clicked() { run(app, "format.textShadow", json!({})); ui.close(); } @@ -2703,8 +2744,8 @@ 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; }; let len = app.session.prefs.units; @@ -2712,14 +2753,14 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) { // " cm" needs more room than the inch mark. let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 }; ui.horizontal(|ui| { - ui.label("Height:"); - if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) { + ui.label(crate::i18n::tr("Height:")); + if let Some(v) = widgets::spinner(ui, "sh", crate::i18n::tr("Height"), x.h / ppu, 0.1, suffix, width) { run(app, "shape.resize", json!({"h": v.max(0.0) * ppu})); } }); ui.horizontal(|ui| { - ui.label("Width: "); - if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) { + ui.label(crate::i18n::tr("Width: ")); + if let Some(v) = widgets::spinner(ui, "sw", crate::i18n::tr("Width"), x.w / ppu, 0.1, suffix, width) { run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); } }); @@ -2737,7 +2778,7 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::RemoveBackground, "Remove\nBackground", enabled(app, "picture.removeBackground")); egui::Popup::menu(&r).show(|ui| { for (label, tol) in [("Remove Background", 20.0), ("Remove More (looser)", 35.0), ("Remove Less (tighter)", 10.0)] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::tr(label)).clicked() { run(app, "picture.removeBackground", json!({"tolerance": tol})); } } @@ -2745,13 +2786,13 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { 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})); } } @@ -2759,15 +2800,15 @@ 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})); } } ui.separator(); - ui.label("Recolor"); + ui.label(crate::i18n::tr("Recolor")); let sc = scheme(app); let presets = deckcraft_engine::cmd::recolor::presets(); - let names: Vec = presets.iter().map(|(_, l)| l.clone()).collect(); + let names: Vec = presets.iter().map(|(_, l)| crate::i18n::tr(l).to_string()).collect(); if let Some(i) = widgets::gallery( ui, presets.len(), @@ -2824,7 +2865,7 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { use deckcraft_engine::cmd::picture_style::{STYLES, look}; - let names: Vec = STYLES.iter().map(|(_, l)| l.to_string()).collect(); + let names: Vec = STYLES.iter().map(|(_, l)| crate::i18n::tr(l).to_string()).collect(); if let Some(i) = widgets::gallery( ui, STYLES.len(), @@ -2839,12 +2880,12 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let sc = scheme(app); - let (m, a) = color_split(ui, Icon::QuickStyles, "Picture Border", Color32::BLACK, true); + let (m, a) = color_split(ui, Icon::QuickStyles, crate::i18n::tr("Picture Border"), Color32::BLACK, true); if m.clicked() { run(app, "shape.line", json!({"color": "#000000", "width": 1.0})); } widgets::color_popup(&a).show(|ui| { - if let Some(c) = widgets::color_grid(ui, &sc, Some("No Outline")) { + if let Some(c) = widgets::color_grid(ui, &sc, Some(crate::i18n::tr("No Outline"))) { match c { Some(c) => run(app, "shape.line", json!({"color": cref_param(&c)})), None => run(app, "shape.line", json!({"none": true})), @@ -2882,13 +2923,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})); } } @@ -2907,31 +2948,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})); } }); @@ -2964,7 +3005,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(); } @@ -3183,7 +3224,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).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { + if ui.small_button(crate::i18n::tr(l)).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() { run(app, "table.cellAlign", json!({"anchor": a})); } } @@ -3210,7 +3251,7 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); continue; }; - 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:?}")})); @@ -3338,29 +3379,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})); } }); @@ -3369,7 +3410,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})); } } @@ -3419,7 +3460,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 7055f9b2f..ab258b3c2 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1021,7 +1021,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), ); @@ -1328,7 +1328,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), ); @@ -1337,7 +1337,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() { @@ -1400,7 +1400,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, ); @@ -2186,11 +2194,15 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no let mut actions = vec![]; // The presenter window is dark; default buttons draw dark text on it (#221). let button = |ui: &mut Ui, s: &str| { - ui.add(egui::Button::new(egui::RichText::new(s).size(15.0).color(Color32::WHITE)).fill(Color32::from_gray(60)).min_size(vec2(0.0, 30.0))) + ui.add( + egui::Button::new(egui::RichText::new(crate::i18n::tr(s)).size(15.0).color(Color32::WHITE)) + .fill(Color32::from_gray(60)) + .min_size(vec2(0.0, 30.0)), + ) }; 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, _| { if !keyboard(ui.ctx(), show, doc, now, true) { *keep = false; @@ -2208,7 +2220,11 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no ui.add_space(12.0); ui.label(egui::RichText::new(clock.clone()).size(16.0).color(Color32::from_gray(200))); 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 button(ui, "End Show").clicked() { *keep = false; @@ -2287,14 +2303,20 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no ui.painter().rect_filled(cr, CornerRadius::ZERO, c); } let nr = next_rect(right, doc); - 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_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.0)); diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 210a802c4..494b55ac5 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -422,7 +422,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) }; let width = notes_rect.width(); let r = crate::notes::editor(&mut child, &mut draft, &look, |e| { - e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE) + e.id(edit_id).hint_text(crate::i18n::tr("Click to add text")).desired_width(width).desired_rows(12).frame(egui::Frame::NONE) }) .response .response; diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 0301af446..4c88c7bcb 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -17,8 +17,10 @@ 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(); // Left to right: slide counter, language, status message. Each starts at its usual column, or @@ -29,14 +31,15 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { let right_x = rect.max.x - 560.0; // The selection's proofing language; a click opens Review › Language. let (lang, no_proof) = crate::dialogs::selected_language(app); - let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { " (not checked)" } else { "" }; + let name = crate::i18n::tr(&crate::squiggles::display_lang_name(ui.ctx(), &lang)).to_string() + + if no_proof { crate::i18n::tr(" (not checked)") } else { "" }; let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0); let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y)); let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); let hit = lr.expand(3.0).intersect(lclip); if hit.is_positive() { - let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language"); - resp.widget_info(|| named_with_value("Language", &name)); + let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text(crate::i18n::tr("Language")); + resp.widget_info(|| named_with_value(crate::i18n::tr("Language"), &name)); if resp.clicked() { app.dialog = Some(crate::dialogs::Dialog::new("language")); } @@ -118,6 +121,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { /// Draw a small status icon with a tooltip and return its interaction response. fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { let resp = icon_button(ui, icon, on); + let tip = crate::i18n::tr(tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); resp.on_hover_text(tip) } @@ -125,6 +129,7 @@ fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { /// A view button: screen readers hear its name and whether its view is the one shown. fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { let resp = icon_button(ui, icon, on); + let tip = crate::i18n::tr(tip); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip)); resp.on_hover_text(tip) } @@ -155,6 +160,7 @@ fn shown_text(ui: &Ui, rect: Rect, id: &str, text: &str) { /// Draw a labelled status icon, painting the text directly and exposing the toggle state as its /// accessible selected state. 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()); @@ -162,10 +168,10 @@ 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); // The visible label is painted directly, so name the interactive control explicitly and // expose the toggle state as selected: a screen reader hears "Notes, pressed" not "Notes". - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, crate::i18n::tr(label))); resp } @@ -215,7 +221,11 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { .auto_shrink([false, false]) .show(ui, |ui| { crate::notes::editor(ui, &mut draft, &look, |e| { - e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) + e.id(id) + .hint_text(crate::i18n::tr("Click to add notes")) + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .desired_rows(2) }) }) .inner; diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index c6643c20a..fbdf46369 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -18,6 +18,7 @@ struct Key { master: usize, size: (u32, u32), edit: bool, + language: crate::i18n::Language, extra: u64, /// The pasteboard region the slide's drawing calls for (canvas only). want: Region, @@ -291,6 +292,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 ^ anim_key(anim), want, editing_text, @@ -308,6 +310,7 @@ impl Textures { scale, edit, gif_times, + placeholder_prompt: Some(crate::i18n::placeholder_prompt), threads: threads(), offset: region.offset(), size: Some(region.size()), @@ -374,6 +377,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, want: Region::slide(width_px, h), editing_text: None, @@ -494,3 +498,48 @@ mod fit_tests { assert_eq!(shrink_to(ok.clone(), 2048).size, ok.size); } } + +#[cfg(test)] +mod language_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), Region::slide(480, 270), true, 0, false, &[], None, None).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 25be3e8c2..3127f11df 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -83,25 +83,25 @@ fn list(app: &mut SlideApp, ui: &mut Ui) { t.text_dim, ); resp.context_menu(|ui| { - if ui.button("Rename Section…").clicked() { + if ui.button(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(); } @@ -117,7 +117,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 }, ); @@ -213,7 +213,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!({})); @@ -229,30 +229,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() @@ -265,11 +265,11 @@ pub fn slide_menu(app: &mut SlideApp, ui: &mut Ui, i: usize) { } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(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(); } @@ -328,7 +328,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 5bfbf041e..0afd75150 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -49,6 +49,7 @@ fn big_button_width(text_w: f32, split: bool) -> f32 { /// response). Like Office's split buttons, the icon and the label text run the main command; only /// the chevron at the end of the label row opens the menu. pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option) { + 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); @@ -148,6 +149,8 @@ pub fn small_button_named( selected: bool, split: bool, ) -> (Response, Option) { + let label = crate::i18n::tr(label); + let name = crate::i18n::tr(name); 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 }; @@ -172,7 +175,7 @@ pub fn small_button_named( if split { main_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, name)); if let Some(a) = &arrow_resp { - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options"))); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, crate::i18n::format("{} options", &[name.to_string()]))); } } if let Some(c) = bg(ui, &main_resp, selected, &t) { @@ -269,13 +272,16 @@ 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); - // Named for screen readers (and tests) by its tooltip. + // Named for screen readers (and tests) by its tooltip, in the interface language. + let tip = crate::i18n::tr(tip); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip)); resp.on_hover_text(tip) } /// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…). pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: bool) -> (Response, Response) { + // The accessible names and the tooltip, in the interface language. + let tip = crate::i18n::tr(tip); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover()); let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() }); @@ -285,7 +291,7 @@ pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: resp.id.with("a"), if enabled { Sense::click() } else { Sense::hover() }, ); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{tip} options"))); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, crate::i18n::format("{} options", &[tip.to_string()]))); let painter = ui.painter_at(rect); if let Some(c) = bg(ui, &main, false, &t) { painter.rect_filled(main.rect, CornerRadius::same(4), c); @@ -384,7 +390,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, @@ -407,7 +413,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 { @@ -446,7 +452,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 { @@ -457,7 +463,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); } @@ -465,7 +471,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - let id = ui.scope_id().with("hex"); let mut hex: String = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); ui.horizontal(|ui| { - ui.label("More Colors… #"); + 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)) @@ -507,7 +513,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 9ec41ee8e..5d06f761e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where DeckCraft falls short of PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** minor (Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (Ukrainian and Polish UI catalogs and language selection; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every known shortfall, one entry each, ranked by how much it stops someone who presents with @@ -22,7 +22,7 @@ revision history. | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops media options and rewind (#74, #77) | File format | B | 2–4 | [file-format-parity.md](file-format-parity.md) | | 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 4–8 | [file-format-parity.md](file-format-parity.md) | -| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | +| 5 | Target languages beyond English missing; Ukrainian and Polish 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) | @@ -335,13 +335,14 @@ 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). - **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face (Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK. Adding an openly licensed Korean face (e.g. Nanum Gothic, OFL) to craft-fonts: 1–2 h. +- **Target languages beyond English missing.** English, Ukrainian and Polish 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). - **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. @@ -377,6 +378,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-22 | minor | Animation Pane ▸ Play From starts the preview at the selected effect; gap 10 keeps the effect gallery (7–13 h) | | 2026-10-24 | trivial | Columns dialog (number and spacing of text columns); dialog count 41 → 42 | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | +| 2026-10-19 | minor | Localization gap names the Polish UI catalog next to the Ukrainian one | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged | | 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions | @@ -445,5 +447,6 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | | 2026-10-11 | minor | Removed "Tab inside a text box ends text entry" (#78, fixed) | | 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF | +| 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 231177ad1..f07a9e4a4 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,19 +1,23 @@ # Localization parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (Polish UI catalog; previous: minor, opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (one proofing language for the whole presentation; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -DeckCraft's interface is English only today: strings are literals in the UI and engine code, with -no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34 -(Ukrainian, open). Dimension score: **~8% ready** (estimated: English complete, slide-text script +DeckCraft's interface supports English, Ukrainian and Polish through `crates/ui-egui/src/i18n/uk.tsv` +and `crates/ui-egui/src/i18n/pl.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, @@ -27,6 +31,7 @@ support partly there), **55–90 h** to parity plus native-speaker review. |---|---|---|---|---|---|---|---|---| | English | en | ~1,050 / ~1,050 (100%) | yes / yes / no help | full | yes | **full** | yes | 0 | | Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | native candidate optionally embeds LXGW WenKai Lite via craft-fonts, with existing per-character fallback; other builds use system CJK fonts (Noto CJK remains excluded); UAX #14 breaking; IME composition (#101); no true vertical layout | no | none | yes | 6–10 (incl. IME) | +| Ukrainian | uk | UI catalog; coverage checked by tests | UI captions; diagnostics retain original wording | Cyrillic; bundled UI fonts tested | no | available; English fallback | no | — | | 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 | | Arabic | ar | 0 (0%) | no | bidi, contextual shaping, kashida justification, RTL bullets and caret (#4); **UI not mirrored**; browser export shows boxes (#81) | no | none | yes | 10–16 (incl. UI mirroring) | @@ -38,7 +43,7 @@ support partly there), **55–90 h** to parity plus native-speaker review. | Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 | | Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 | -Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more +Other languages shipped: **2 (Ukrainian, Polish)**. The Polish catalog (`i18n/pl.tsv`) has the same 1,232 keys as the Ukrainian one, its own integer-count forms, and no second native-speaker review yet. 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. @@ -48,6 +53,8 @@ the system (#15), plural and number formatting, and an `xtask` check that every translation. IME composition landed with #101 (slide text, table cells, notes and text fields; one undo step per composition, underlined in place); the hours that included it are kept until the next re-measure. +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. ## Proofing languages @@ -65,4 +72,6 @@ of every text in the file and the default for new text (`format.languageAll`). | 2026-10-21 | minor | Review › Language can set the proofing language of the whole presentation | | 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret | | 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) | +| 2026-10-12 | minor | Added Polish catalog (1,232 keys, same set as Ukrainian), `pl` locales and Polish count forms | +| 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 205d96070..06840abc6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # DeckCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (beta item 9 drops Play From, now in; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (Ukrainian and Polish UI catalogs and language selection; previous: trivial, beta item 9 drops Play From, now in; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Forward-looking: where DeckCraft is going and in what order. The one-page summary is [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in @@ -34,7 +34,7 @@ points and lead the beta plan. this repo, media options and rewind on save (#74, #77), relationship-bearing SmartArt and OLE (#203), EMF+ records and SVG. 31–55 h. 3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS - open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h. + open-with (#68), font lookup (#80). 4–8 h. Ukrainian UI and its catalog are available (#34). ## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents) @@ -48,7 +48,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, MicroType embedded-font subsets | 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 | | 10 | Presenter view on the right display, swap displays | 2–4 | Hardware, Slide show | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7e797ac02..4f7550eeb 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-14 · **Last updated:** 2026-10-22 · **Change:** trivial (Animations shortfall: preview from the selected effect is in; previous: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** minor (Ukrainian and Polish UI catalogs and language selection; previous: trivial, Animations shortfall: preview from the selected effect is in; previous: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **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 tilt, 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 Polish UI catalogs with system-language selection. PowerPoint for Mac ships 26 languages; Ukrainian is outside that count. Bidi and Arabic shaping in slide text exist | | Ecosystem (templates, add-ins) | 3% | ~25% | 10–20 | [gaps.md](gaps.md) | 8 original themes and 6 original starter decks; PowerPoint's large template gallery remains; 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** | | |