diff --git a/crates/ui-egui/src/animtimeline.rs b/crates/ui-egui/src/animtimeline.rs index bdd1a1dc6..5a5a65564 100644 --- a/crates/ui-egui/src/animtimeline.rs +++ b/crates/ui-egui/src/animtimeline.rs @@ -87,7 +87,7 @@ pub fn dragged_duration(duration_ms: u32, dx: f32, pps: f32, factor: f64) -> u32 /// Tooltip text for a bar: `Start: 0.5s, End: 2.5s`. pub fn label(bar: &Bar) -> String { - format!("Start: {:.2}s, End: {:.2}s", bar.start, bar.start + bar.span) + crate::tl!("Start: {}s, End: {}s", format!("{:.2}", bar.start), format!("{:.2}", bar.start + bar.span)) } #[cfg(test)] diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index fd412028a..e5b7c0d89 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -339,7 +339,8 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.spacing_mut().item_spacing.y = 0.0; if has_doc { let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back")); + let back_label = crate::tl!("Back"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, back_label)); let hovered = resp.hovered(); if hovered { ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover); @@ -351,7 +352,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { t.text, false, ); - if resp.on_hover_text("Back (Esc)").clicked() { + if resp.on_hover_text(crate::tl!("Back (Esc)")).clicked() { out.close = true; } } else { @@ -402,7 +403,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { egui::Frame::NONE.inner_margin(egui::Margin { left: 48, right: 36, top: 22, bottom: 32 }).show(ui, |ui| { ui.set_max_width(CONTENT_W); if bs.page != Page::Home { - ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text)); + ui.label(RichText::new(crate::i18n::t(bs.page.label())).font(theme::bold(22.0)).color(t.text)); ui.add_space(16.0); } // Errors raised on a backstage page (a missing recent file, a failed open) would @@ -443,7 +444,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click()); let selected = nav == Nav::Page(bs.page); - let label = nav.label(); + let label = crate::i18n::t(nav.label()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let p = ui.painter(); let row = rect.shrink2(vec2(6.0, 1.0)); @@ -471,18 +472,19 @@ fn separator(ui: &mut Ui, t: &Tokens) { fn heading(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(text).font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(crate::i18n::t(text)).font(theme::bold(16.0)).color(t.text)); ui.add_space(8.0); } fn dim(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); - ui.label(RichText::new(text).font(theme::font(12.5)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::t(text)).font(theme::font(12.5)).color(t.text_dim)); } /// A link with an arrow (More themes →): true when clicked. fn arrow_link(ui: &mut Ui, text: &str) -> bool { let t = Tokens::get(ui.ctx()); + let text = crate::i18n::t(text); ui.add(egui::Label::new(RichText::new(format!("{text} →")).font(theme::font(12.5)).color(t.accent)).sense(Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand) .clicked() @@ -490,12 +492,13 @@ fn arrow_link(ui: &mut Ui, text: &str) -> bool { /// A search box; true when its text changed. fn search(ui: &mut Ui, query: &mut String, hint: &str, width: f32) -> bool { - ui.add(egui::TextEdit::singleline(query).hint_text(hint).desired_width(width)).changed() + ui.add(egui::TextEdit::singleline(query).hint_text(crate::i18n::t(hint)).desired_width(width)).changed() } /// A new-presentation tile: a theme preview drawn in code (or a blank slide) over its name. fn tile(ui: &mut Ui, label: &str, theme: Option<&deckcraft_model::Theme>, big: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = crate::i18n::t(label); let thumb_w = if big { 200.0 } else { 160.0 }; let (rect, resp) = ui.allocate_exact_size(vec2(thumb_w + 16.0, thumb_w * 9.0 / 16.0 + 44.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); @@ -566,7 +569,7 @@ fn template_tiles(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) -> ui.spacing_mut().item_spacing = vec2(10.0, 10.0); for starter in list.into_iter().take(limit) { let theme = deckcraft_model::theme::builtin_themes().into_iter().find(|th| th.name == starter.theme); - if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(starter.summary).clicked() { + if tile(ui, starter.name, theme.as_ref(), false).on_hover_text(crate::i18n::t(starter.summary)).clicked() { let _ = app.run("file.new", json!({"template": starter.name})); } } @@ -594,7 +597,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { let list: Vec = app.ui.recent.iter().filter(|p| q.is_empty() || split_path(p).0.to_lowercase().contains(&q)).take(limit).cloned().collect(); if list.is_empty() { - dim(ui, &format!("No recent presentations match “{}”.", query.trim())); + dim(ui, &crate::tl!("No recent presentations match “{query}”.", query = query.trim())); return; } let width = ui.available_width().min(CONTENT_W); @@ -618,22 +621,33 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim); // A missing file says so before it is clicked (#172). if missing { - p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim); + p.text( + pos2(visible_right - 82.0, rect.center().y), + Align2::RIGHT_CENTER, + crate::tl!("(missing)"), + theme::font(11.0), + t.text_dim, + ); } } - let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) }; + let resp = if missing { + resp.on_hover_text(crate::tl!("{path} — this file can't be found", path = path)) + } else { + resp.on_hover_text(&path) + }; // Remove from the list: a real labelled widget on top of the row, with a code-drawn icon // (a text glyph would need a font that has it). let x = ui.allocate_rect( Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)), Sense::click(), ); - x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Remove from Recent")); + let remove_label = crate::tl!("Remove from Recent"); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, remove_label)); if x.hovered() { ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false); - if x.on_hover_text("Remove from Recent").clicked() { + if x.on_hover_text(remove_label).clicked() { app.forget_recent(&path); } if resp.clicked() { @@ -645,6 +659,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) { /// A large square command button (Print, Create PDF, Check for Issues…). fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = crate::i18n::t(label); let (rect, resp) = ui.allocate_exact_size(vec2(88.0, 80.0), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); let p = ui.painter(); @@ -660,6 +675,7 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::R /// A row of a page's own list on the left (Open's places, Export's tasks). fn place_row(ui: &mut Ui, icon: Icon, label: &str, selected: bool, width: f32) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = crate::i18n::t(label); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let p = ui.painter(); @@ -683,7 +699,7 @@ fn task(ui: &mut Ui, icon: Icon, button: &str, title: &str, detail: &str, enable ui.add_space(12.0); ui.vertical(|ui| { ui.add_space(4.0); - ui.label(RichText::new(title).font(theme::font(15.0)).color(t.text)); + ui.label(RichText::new(crate::i18n::t(title)).font(theme::font(15.0)).color(t.text)); dim(ui, detail); }); }); @@ -704,7 +720,7 @@ fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) }); ui.add_space(18.0); ui.horizontal(|ui| { - ui.label(RichText::new("New").font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(crate::tl!("New")).font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if arrow_link(ui, "More themes") { out.page = Some(Page::New); @@ -715,7 +731,7 @@ fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) new_tiles(app, ui, "", 5, true); ui.add_space(22.0); ui.horizontal(|ui| { - ui.label(RichText::new("Templates").font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(crate::tl!("Templates")).font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if arrow_link(ui, "More templates") { out.page = Some(Page::New); @@ -726,7 +742,7 @@ fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) template_tiles(app, ui, "", 3); ui.add_space(22.0); ui.horizontal(|ui| { - ui.label(RichText::new("Recent").font(theme::bold(16.0)).color(t.text)); + ui.label(RichText::new(crate::tl!("Recent")).font(theme::bold(16.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { search(ui, &mut bs.query, "Search for a file", 280.0); }); @@ -742,7 +758,7 @@ fn home(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) }); } ui.add_space(30.0); - ui.hyperlink_to("Join the ArtCraft community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to(crate::tl!("Join the ArtCraft community on Discord"), deckcraft_engine::links::DISCORD); } /// New: a blank presentation, then every theme and starter template, with a search box. @@ -768,7 +784,7 @@ fn new_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { false }; if !themes && !templates { - dim(ui, &format!("No themes or templates match “{}”.", q.trim())); + dim(ui, &crate::tl!("No themes or templates match “{query}”.", query = q.trim())); } } @@ -779,15 +795,18 @@ fn open_page(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.vertical(|ui| { ui.set_width(230.0); place_row(ui, Icon::Recent, "Recent", true, 220.0); - if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0).on_hover_text("Open a presentation from a folder (Ctrl+O)").clicked() { + if place_row(ui, Icon::OpenFolder, "Browse", false, 220.0) + .on_hover_text(crate::tl!("Open a presentation from a folder (Ctrl+O)")) + .clicked() + { let _ = app.run("app.openDialog", json!({})); } if app.session.recovery_dir.is_some() { ui.add_space(24.0); - if ui.button("Recover Unsaved Presentations").clicked() { + if ui.button(crate::tl!("Recover Unsaved Presentations")).clicked() { match app.run("file.recovery.open", json!({})) { Ok(v) if v.get("opened").and_then(Value::as_array).is_some_and(|a| a.is_empty()) => { - app.set_status("There are no unsaved presentations to recover"); + app.set_status(crate::tl!("There are no unsaved presentations to recover")); } _ => {} } @@ -827,13 +846,13 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { if let Some(p) = &path { ui.add_space(8.0); ui.horizontal(|ui| { - if ui.button("Copy path").clicked() { + if ui.button(crate::tl!("Copy path")).clicked() { ui.ctx().copy_text(p.clone()); - app.set_status("Copied the path"); + app.set_status(crate::tl!("Copied the path")); } #[cfg(not(target_arch = "wasm32"))] if let Some(dir) = std::path::Path::new(p).parent().and_then(std::path::Path::to_str) - && ui.button("Open file location").clicked() + && ui.button(crate::tl!("Open file location")).clicked() { app.open_url(&format!("file:///{}", dir.replace('\\', "/").trim_start_matches('/'))); } @@ -869,7 +888,7 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { let size = v.get("bytes").and_then(Value::as_u64).map(format_bytes); egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 7.0)).show(ui, |ui| { let row = |ui: &mut Ui, k: &str, val: String| { - ui.label(RichText::new(k).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::t(k)).color(t.text_dim)); ui.label(val); ui.end_row(); }; @@ -881,9 +900,10 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { for (i, (key, label, hint)) in PROPERTIES.iter().enumerate() { let saved = props.get(*key).and_then(Value::as_str).unwrap_or("").to_string(); let Some(draft) = bs.props.get_mut(i) else { continue }; - let l = ui.label(RichText::new(*label).color(t.text_dim)); + let l = ui.label(RichText::new(crate::i18n::t(label)).color(t.text_dim)); let id = ui.make_persistent_id(("backstage_prop", *key)); - let resp = ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(*hint).desired_width(300.0)).labelled_by(l.id); + let resp = + ui.add(egui::TextEdit::singleline(draft).id(id).hint_text(crate::i18n::t(hint)).desired_width(300.0)).labelled_by(l.id); if resp.lost_focus() && *draft != saved { // Commit first: a click on another field takes the focus before this one is drawn. let mut p = serde_json::Map::new(); @@ -896,7 +916,7 @@ fn info(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } ui.end_row(); } - row(ui, "Location", path.clone().unwrap_or_else(|| "Not saved yet".into())); + row(ui, "Location", path.clone().unwrap_or_else(|| crate::tl!("Not saved yet").to_string())); }); }); }); @@ -935,16 +955,17 @@ fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom let t = Tokens::get(ui.ctx()); let (title, path) = app.session.active().map(|d| (d.title(), d.path.clone())).unwrap_or_default(); ui.label(RichText::new(&title).font(theme::font(15.0)).color(t.text)); - dim(ui, path.as_deref().unwrap_or("Not saved yet")); + let unsaved = crate::tl!("Not saved yet"); + dim(ui, path.as_deref().unwrap_or(unsaved)); ui.add_space(14.0); let current = SAVE_TYPES.iter().find(|s| s.0 == bs.file_type).map_or(SAVE_TYPES[0].1, |s| s.1); ui.horizontal(|ui| { egui::ComboBox::from_id_salt("backstage_save_type") .width(340.0) - .selected_text(current) + .selected_text(crate::i18n::t(current)) .show_ui(ui, |ui| { for (id, label, _) in SAVE_TYPES { - if ui.selectable_label(bs.file_type == id, label).clicked() { + if ui.selectable_label(bs.file_type == id, crate::i18n::t(label)).clicked() { bs.file_type = id; } } @@ -952,9 +973,9 @@ fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom .response .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type")); let web = app.services.pick_save.is_none(); - let label = if web { "Download" } else { "Save" }; + let label = if web { crate::tl!("Download") } else { crate::tl!("Save") }; let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0))); - if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() { + if save.on_hover_text(if web { crate::tl!("Download the file") } else { crate::tl!("Choose where to save") }).clicked() { let fmt = bs.file_type; let done = if matches!(fmt, "pptx" | "deckcraft") { let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); @@ -1061,14 +1082,16 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.add_space(14.0); heading(ui, "Settings"); let scope = match bs.print_scope { - PrintScope::All => "Print All Slides", - PrintScope::Current => "Print Current Slide", - PrintScope::Custom => "Custom Range", + PrintScope::All => crate::tl!("Print All Slides"), + PrintScope::Current => crate::tl!("Print Current Slide"), + PrintScope::Custom => crate::tl!("Custom Range"), }; let combo = egui::ComboBox::from_id_salt("backstage_print_scope").width(240.0).selected_text(scope).show_ui(ui, |ui| { - for (s, label) in - [(PrintScope::All, "Print All Slides"), (PrintScope::Current, "Print Current Slide"), (PrintScope::Custom, "Custom Range")] - { + for (s, label) in [ + (PrintScope::All, crate::tl!("Print All Slides")), + (PrintScope::Current, crate::tl!("Print Current Slide")), + (PrintScope::Custom, crate::tl!("Custom Range")), + ] { if ui.selectable_label(bs.print_scope == s, label).clicked() { bs.print_scope = s; bs.preview_page = 0; @@ -1078,8 +1101,10 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { combo.response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Print range")); ui.add_space(4.0); ui.horizontal(|ui| { - let l = ui.label("Slides:"); - let r = ui.add(egui::TextEdit::singleline(&mut bs.print_range).hint_text("e.g. 1-3, 5").desired_width(176.0)).labelled_by(l.id); + let l = ui.label(crate::tl!("Slides:")); + let r = ui + .add(egui::TextEdit::singleline(&mut bs.print_range).hint_text(crate::tl!("e.g. 1-3, 5")).desired_width(176.0)) + .labelled_by(l.id); if r.changed() { // Typing a range picks Custom Range, as in PowerPoint. bs.print_scope = if bs.print_range.trim().is_empty() { PrintScope::All } else { PrintScope::Custom }; @@ -1088,9 +1113,9 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { }); ui.add_space(4.0); let layout = LAYOUTS.iter().find(|l| l.0 == bs.print_layout).map_or("Full Page Slides", |l| l.1); - let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(layout).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("backstage_print_layout").width(240.0).selected_text(crate::i18n::t(layout)).show_ui(ui, |ui| { for (id, label) in LAYOUTS { - if ui.selectable_label(bs.print_layout == id, label).clicked() { + if ui.selectable_label(bs.print_layout == id, crate::i18n::t(label)).clicked() { bs.print_layout = id; bs.preview_page = 0; } @@ -1104,10 +1129,10 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { let paper = deckcraft_engine::cmd::file::PAPERS.iter().find(|p| p.0 == bs.print_paper).map_or("Letter", |p| p.1); egui::ComboBox::from_id_salt("backstage_print_paper") .width(240.0) - .selected_text(paper) + .selected_text(crate::i18n::t(paper)) .show_ui(ui, |ui| { for (id, label, _) in deckcraft_engine::cmd::file::PAPERS { - if ui.selectable_label(bs.print_paper == id, label).clicked() { + if ui.selectable_label(bs.print_paper == id, crate::i18n::t(label)).clicked() { bs.print_paper = id; } } @@ -1115,12 +1140,12 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { .response .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Paper size")); ui.add_space(4.0); - let orient = if bs.print_landscape { "Landscape Orientation" } else { "Portrait Orientation" }; + let orient = if bs.print_landscape { crate::tl!("Landscape Orientation") } else { crate::tl!("Portrait Orientation") }; egui::ComboBox::from_id_salt("backstage_print_orientation") .width(240.0) .selected_text(orient) .show_ui(ui, |ui| { - for (land, label) in [(false, "Portrait Orientation"), (true, "Landscape Orientation")] { + for (land, label) in [(false, crate::tl!("Portrait Orientation")), (true, crate::tl!("Landscape Orientation"))] { if ui.selectable_label(bs.print_landscape == land, label).clicked() { bs.print_landscape = land; } @@ -1130,12 +1155,12 @@ fn print(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Orientation")); }); ui.add_space(4.0); - if ui.checkbox(&mut bs.print_hidden, "Print Hidden Slides").changed() { + if ui.checkbox(&mut bs.print_hidden, crate::tl!("Print Hidden Slides")).changed() { bs.preview_page = 0; } - ui.checkbox(&mut bs.print_frame, "Frame Slides"); + ui.checkbox(&mut bs.print_frame, crate::tl!("Frame Slides")); ui.add_space(6.0); - if ui.link("Edit Header & Footer").clicked() { + if ui.link(crate::tl!("Edit Header & Footer")).clicked() { let _ = app.run("app.dialog", json!({"id": "headerFooter"})); } }); @@ -1243,7 +1268,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ if prev { bs.preview_page = bs.preview_page.saturating_sub(1); } - ui.label(format!("{} of {}", bs.preview_page + 1, pages.len())); + ui.label(crate::tl!("{page} of {total}", page = bs.preview_page + 1, total = pages.len())); if icon_button(ui, Icon::ChevronRight, "Next Page", bs.preview_page < last) { bs.preview_page = (bs.preview_page + 1).min(last); } @@ -1253,6 +1278,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_ /// Draw a labelled icon button and return whether it was clicked while enabled. fn icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); + let label = crate::i18n::t(label); let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, label)); if resp.hovered() && enabled { @@ -1287,26 +1313,29 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.set_max_width(560.0); match bs.export { ExportTask::Pdf => { - ui.label(RichText::new("Create a PDF Document").font(theme::font(16.0)).color(t.text)); - bullets( - ui, - &["Keeps the layout, fonts and pictures", "Opens on any device with a PDF viewer", "The presentation keeps its own file"], - ); - ui.checkbox(&mut bs.pdf_hidden, "Include hidden slides"); + ui.label(RichText::new(crate::tl!("Create a PDF Document")).font(theme::font(16.0)).color(t.text)); + let b1 = crate::tl!("Keeps the layout, fonts and pictures"); + let b2 = crate::tl!("Opens on any device with a PDF viewer"); + let b3 = crate::tl!("The presentation keeps its own file"); + bullets(ui, &[b1, b2, b3]); + ui.checkbox(&mut bs.pdf_hidden, crate::tl!("Include hidden slides")); ui.add_space(10.0); if square_button(ui, Icon::File, "Create PDF", true).clicked() { app.export_as("pdf", json!({"includeHidden": bs.pdf_hidden})); } } ExportTask::Handouts => { - ui.label(RichText::new("Create Handouts").font(theme::font(16.0)).color(t.text)); - bullets(ui, &["Several slides on each page of a PDF", "Three a page leaves lines for notes", "Print them for your audience"]); + ui.label(RichText::new(crate::tl!("Create Handouts")).font(theme::font(16.0)).color(t.text)); + let b1 = crate::tl!("Several slides on each page of a PDF"); + let b2 = crate::tl!("Three a page leaves lines for notes"); + let b3 = crate::tl!("Print them for your audience"); + bullets(ui, &[b1, b2, b3]); let combo = egui::ComboBox::from_id_salt("backstage_handouts") .width(240.0) - .selected_text(format!("{} slides per page", bs.handouts_per_page)) + .selected_text(crate::tl!("{count} slides per page", count = bs.handouts_per_page)) .show_ui(ui, |ui| { for n in [1u8, 2, 3, 4, 6, 9] { - if ui.selectable_label(bs.handouts_per_page == n, format!("{n} slides per page")).clicked() { + if ui.selectable_label(bs.handouts_per_page == n, crate::tl!("{count} slides per page", count = n)).clicked() { bs.handouts_per_page = n; } } @@ -1318,20 +1347,22 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { } } ExportTask::FileType => { - ui.label(RichText::new("Change File Type").font(theme::font(16.0)).color(t.text)); + ui.label(RichText::new(crate::tl!("Change File Type")).font(theme::font(16.0)).color(t.text)); ui.add_space(8.0); for (id, label, detail) in SAVE_TYPES.iter().filter(|s| s.0 != "pdf") { let selected = bs.file_type == *id; + let label_t = crate::i18n::t(label); + let detail_t = crate::i18n::t(detail); let (rect, resp) = ui.allocate_exact_size(vec2(520.0, 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, *label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label_t)); let p = ui.painter(); if selected { p.rect_filled(rect, CornerRadius::same(2), t.selected); } else if resp.hovered() { p.rect_filled(rect, CornerRadius::same(2), t.hover); } - p.text(pos2(rect.left() + 12.0, rect.center().y - 8.0), Align2::LEFT_CENTER, *label, theme::font(13.0), t.text); - p.text(pos2(rect.left() + 12.0, rect.center().y + 9.0), Align2::LEFT_CENTER, *detail, theme::font(11.5), t.text_dim); + p.text(pos2(rect.left() + 12.0, rect.center().y - 8.0), Align2::LEFT_CENTER, label_t, theme::font(13.0), t.text); + p.text(pos2(rect.left() + 12.0, rect.center().y + 9.0), Align2::LEFT_CENTER, detail_t, theme::font(11.5), t.text_dim); if resp.clicked() { bs.file_type = id; } @@ -1339,8 +1370,8 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) { ui.add_space(12.0); let png = bs.file_type == "png"; if png { - ui.checkbox(&mut bs.png_transparent, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn"); + ui.checkbox(&mut bs.png_transparent, crate::tl!("Transparent background")) + .on_hover_text(crate::tl!("Leave the slide background out: only what is on the slide is drawn")); ui.add_space(8.0); } if square_button(ui, Icon::SaveAs, "Save As", true).clicked() { @@ -1375,9 +1406,9 @@ fn account(app: &mut SlideApp, ui: &mut Ui) { let dark = app.ui.brightness == theme::Brightness::Dark; egui::ComboBox::from_id_salt("backstage_appearance") .width(260.0) - .selected_text(if dark { "Dark" } else { "Light" }) + .selected_text(if dark { crate::tl!("Dark") } else { crate::tl!("Light") }) .show_ui(ui, |ui| { - for (on, label) in [(false, "Light"), (true, "Dark")] { + for (on, label) in [(false, crate::tl!("Light")), (true, crate::tl!("Dark"))] { if ui.selectable_label(dark == on, label).clicked() { let _ = app.run("view.dark", json!({"on": on})); } @@ -1385,6 +1416,29 @@ fn account(app: &mut SlideApp, ui: &mut Ui) { }) .response .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance")); + ui.add_space(12.0); + heading(ui, "Language"); + let cur_lang = app.ui.language.clone(); + let selected_lang = match cur_lang.as_str() { + "en" => crate::i18n::Lang::EN.display_name().to_string(), + "vi" => crate::i18n::Lang::VI.display_name().to_string(), + _ => crate::tl!("System Default").to_string(), + }; + egui::ComboBox::from_id_salt("backstage_language") + .width(260.0) + .selected_text(selected_lang) + .show_ui(ui, |ui| { + if ui.selectable_label(cur_lang == "auto", crate::tl!("System Default")).clicked() { + let _ = app.run("ui.language", json!({"language": "auto"})); + } + for lang in crate::i18n::Lang::ALL { + if ui.selectable_label(cur_lang == lang.code(), lang.display_name()).clicked() { + let _ = app.run("ui.language", json!({"language": lang.code()})); + } + } + }) + .response + .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Language")); ui.add_space(16.0); heading(ui, "User Information"); dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device."); @@ -1410,8 +1464,8 @@ fn account(app: &mut SlideApp, ui: &mut Ui) { ) { let _ = app.run("app.about", json!({})); } - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); - ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); + ui.hyperlink_to(crate::tl!("Join our community on Discord"), deckcraft_engine::links::DISCORD); + ui.hyperlink_to(crate::tl!("Source on GitHub"), deckcraft_engine::links::GITHUB); }); }); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0ae83b696..97dfa1c0b 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::tl!("Click to add first slide"), theme::font(22.0), t.text_dim); if resp.clicked() { let _ = app.run("slide.new", json!({"layout": "title"})); } @@ -745,8 +745,9 @@ fn ruler_markers(app: &mut SlideApp, ui: &mut Ui, p: &egui::Painter, avail: Rect ui.data_mut(|d| d.insert_temp(kind_id, kind)); } let (kind_align, kind_label) = KINDS[kind % KINDS.len()]; + let kind_label = crate::i18n::t(kind_label); cr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, kind_label)); - let cr = cr.on_hover_text(format!("{kind_label} (click to change)")); + let cr = cr.on_hover_text(crate::tl!("{kind_label} (click to change)", kind_label = kind_label)); p.rect_stroke( corner.shrink(2.0), CornerRadius::same(2), @@ -844,14 +845,14 @@ fn draw_guides(app: &SlideApp, painter: &egui::Painter, xf: Xf, slide: Rect, t: if drag.is_some_and(|(di, _)| di == i) { let centre = if g.vertical { size.width / 2.0 } else { size.height / 2.0 }; let off = pos - centre; - let side = match (g.vertical, off < 0.0, off > 0.0) { - (true, true, _) => " left of centre", - (true, _, true) => " right of centre", - (false, true, _) => " above centre", - (false, _, true) => " below centre", - _ => " (centre)", + let off_pt = format!("{:.0}", off.abs()); + let label = match (g.vertical, off < 0.0, off > 0.0) { + (true, true, _) => crate::tl!("{off_pt} pt left of centre", off_pt = off_pt), + (true, _, true) => crate::tl!("{off_pt} pt right of centre", off_pt = off_pt), + (false, true, _) => crate::tl!("{off_pt} pt above centre", off_pt = off_pt), + (false, _, true) => crate::tl!("{off_pt} pt below centre", off_pt = off_pt), + _ => crate::tl!("{off_pt} pt (centre)", off_pt = off_pt), }; - let label = format!("{:.0} pt{side}", off.abs()); let at = if g.vertical { pos2(a.x + 6.0, slide.min.y + 6.0) } else { pos2(slide.min.x + 6.0, a.y + 4.0) }; let galley = painter.layout_no_wrap(label, theme::font(11.0), t.text); let r = Rect::from_min_size(at, galley.size()).expand(3.0); @@ -1286,13 +1287,17 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { ui.separator(); } else if let Some((path, index)) = app.session.tool.edit_vertex { let cur = deckcraft_engine::cmd::points::kind_of(&sh, path, index); - for (l, k) in [("Smooth Point", "smooth"), ("Straight Point", "straight"), ("Corner Point", "corner")] { + for (l, k) in [ + (crate::tl!("Smooth Point"), "smooth"), + (crate::tl!("Straight Point"), "straight"), + (crate::tl!("Corner Point"), "corner"), + ] { if ui.radio(cur == Some(k), l).clicked() { let _ = app.run("shape.pointType", json!({"path": path, "index": index, "type": k})); ui.close(); } } - if ui.button("Delete Point").clicked() { + if ui.button(crate::tl!("Delete Point")).clicked() { let _ = app.run("shape.deletePoint", json!({"path": path, "index": index})); app.session.tool.edit_vertex = None; ui.close(); @@ -1309,7 +1314,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } ui.separator(); } - if ui.button("Exit Edit Points").clicked() { + if ui.button(crate::tl!("Exit Edit Points")).clicked() { let _ = app.run("shape.editPoints", json!({"on": false})); ui.close(); } @@ -1318,7 +1323,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { // A misspelt word under the pointer: its suggestions, then Ignore All and Add to Dictionary. if let Some(w) = app.squiggles.menu_word.clone() { if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + if ui.button(egui::RichText::new(crate::tl!("Delete Repeated Word")).strong()).clicked() { let cut: String = format!("{}{}", " ".repeat(w.start - from), w.word); // The gap is spaces; take it from the text so tabs or no-break spaces match. let gap = app @@ -1355,7 +1360,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { ui.close(); } } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + ui.label(egui::RichText::new(crate::tl!("(no spelling suggestions)")).weak()); } for to in &w.suggestions { if ui.button(egui::RichText::new(to).strong()).clicked() { @@ -1371,12 +1376,12 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { ui.close(); } } - if ui.button("Ignore All").clicked() { + if ui.button(crate::tl!("Ignore All")).clicked() { app.squiggles.ignore(&w.word); app.squiggles.menu_word = None; ui.close(); } - if ui.button("Add to Dictionary").clicked() { + if ui.button(crate::tl!("Add to Dictionary")).clicked() { if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { app.set_status(e.to_string()); } @@ -1386,7 +1391,7 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { ui.separator(); } 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")] { + for (l, id) in [(crate::tl!("Cut"), "edit.cut"), (crate::tl!("Copy"), "edit.copy"), (crate::tl!("Paste"), "edit.paste")] { if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { let _ = app.run(id, json!({})); ui.close(); @@ -1396,59 +1401,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::tl!("Paragraph…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("paragraph")); ui.close(); } - for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] { + for (l, id) in [(crate::tl!("Bullets"), "format.bullets"), (crate::tl!("Numbering"), "format.numbering")] { if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() { let _ = app.run(id, json!({})); ui.close(); } } - if ui.button("Link…").clicked() { + if ui.button(crate::tl!("Link…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); } ui.separator(); - if ui.button("Format Shape…").clicked() { + if ui.button(crate::tl!("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::tl!("Edit Text")).clicked() { let _ = app.run("text.edit", json!({})); ui.close(); } if crate::ribbon::enabled(app, "shape.editPoints") && app.session.active().is_some_and(|d| d.selection.shapes.len() == 1 && d.selected_shapes().iter().all(|s| !s.is_line())) - && ui.button("Edit Points").clicked() + && ui.button(crate::tl!("Edit Points")).clicked() { let _ = app.run("shape.editPoints", json!({"on": true})); ui.close(); } - if ui.button("Font…").clicked() { + if ui.button(crate::tl!("Font…")).clicked() { let _ = app.run("format.fontDialog", json!({})); ui.close(); } - if ui.button("Paragraph…").clicked() { + if ui.button(crate::tl!("Paragraph…")).clicked() { let _ = app.run("format.paragraphDialog", json!({})); ui.close(); } - ui.menu_button("Bring to Front", |ui| { + ui.menu_button(crate::tl!("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::tl!("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::tl!("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::tl!("Unlock") } else { crate::tl!("Lock") }).clicked() { let _ = app.run("shape.lock", json!({"locked": !locked})); ui.close(); } @@ -1456,50 +1461,50 @@ 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::tl!("Change Picture…")).clicked() { let _ = app.run("app.changePictureDialog", json!({})); ui.close(); } - if ui.button("Link…").clicked() { + if ui.button(crate::tl!("Link…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("hyperlink")); ui.close(); } - if ui.button("Edit Alt Text…").clicked() { + if ui.button(crate::tl!("Edit Alt Text…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("altText")); ui.close(); } - if ui.button("Set as Default Shape").clicked() { + if ui.button(crate::tl!("Set as Default Shape")).clicked() { let _ = app.run("shape.setDefault", json!({})); ui.close(); } - if ui.button("Size and Position…").clicked() { + if ui.button(crate::tl!("Size and Position…")).clicked() { let _ = app.run("shape.sizePosition", json!({})); ui.close(); } - if ui.button("Format Shape…").clicked() { + if ui.button(crate::tl!("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format"})); ui.close(); } } else { - ui.menu_button("Layout", |ui| { + ui.menu_button(crate::tl!("Layout"), |ui| { let layouts: Vec<(String, u32)> = app .session .active() .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) .unwrap_or_default(); for (n, id) in layouts { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(&n)).clicked() { let _ = app.run("slide.layout", json!({"layout": id})); ui.close(); } } }); - ui.menu_button("Grid and Guides", |ui| { + ui.menu_button(crate::tl!("Grid and Guides"), |ui| { let mut on = app.ui.guides; - if ui.checkbox(&mut on, "Guides").clicked() { + if ui.checkbox(&mut on, crate::tl!("Guides")).clicked() { let _ = app.run("view.guides", json!({"on": on})); } - for (label, vertical) in [("Add Vertical Guide", true), ("Add Horizontal Guide", false)] { + for (label, vertical) in [(crate::tl!("Add Vertical Guide"), true), (crate::tl!("Add Horizontal Guide"), false)] { if ui.button(label).clicked() { let _ = app.run("guide.add", json!({"vertical": vertical})); let _ = app.run("view.guides", json!({"on": true})); @@ -1507,15 +1512,15 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) { } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(crate::tl!("Reset Slide")).clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); } - if ui.button("Format Background…").clicked() { + if ui.button(crate::tl!("Format Background…")).clicked() { let _ = app.run("view.pane", json!({"pane": "background"})); ui.close(); } - if ui.button("New Comment").clicked() { + if ui.button(crate::tl!("New Comment")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("comment")); ui.close(); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8dc21d914..5693e1d51 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -284,15 +284,28 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> if let Some(ft) = s("formatTab") { app.ui.format_tab = ft.to_string(); } + if let Some(lang) = s("language").or(s("lang")) { + let norm = crate::i18n::Lang::normalize_pref(lang); + app.ui.language = norm.to_owned(); + crate::i18n::set_current(crate::i18n::Lang::from_pref(norm)); + } ok(serde_json::to_value(&app.ui).unwrap_or_default()) } "ui.dialog.close" => { - app.dialog = None; + if let Some(d) = app.dialog.take() + && d.id == "preferences" + { + crate::dialogs::restore_preferences(app, &d); + } ok(Value::Null) } "ui.dialog.open" => { let Some(id) = s("id") else { return err("missing `id`") }; - let mut d = crate::dialogs::Dialog::new(id); + let mut d = if id == "preferences" { + crate::dialogs::Dialog::preferences(app) + } else { + crate::dialogs::Dialog::new(id) + }; if let Some(fields) = p.get("fields").and_then(Value::as_object) { for (k, v) in fields { let v = v.as_str().map_or_else(|| v.to_string(), str::to_string); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 0cef384bf..7904c5718 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -215,40 +215,42 @@ 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::tl!("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::t(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::tl!("Sort")); + let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(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::t(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::tl!("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); - if ui.selectable_label(!v.table, "Grab bag").clicked() { + if ui.selectable_label(!v.table, crate::tl!("Grab bag")).clicked() { v.table = false; } - if ui.selectable_label(v.table, "Table").clicked() { + if ui.selectable_label(v.table, crate::tl!("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::tl!("{count} contributors · {commits} commits", count = list.len(), commits = group(TOTAL_COMMITS))).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::tl!("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v); } else { @@ -269,7 +271,7 @@ 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::t(k.label().1))).strong()).on_hover_text(crate::i18n::t(k.label().0)).clicked() { if v.key == k { v.ascending = !v.ascending; } else { @@ -298,13 +300,20 @@ 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::tl!("No model credits were built into this copy.")); return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); 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 +/−"] { + for h in [ + crate::tl!("Company"), + crate::tl!("Model"), + crate::tl!("Version"), + crate::tl!("Commits"), + crate::tl!("% of all commits"), + crate::tl!("Lines +/−"), + ] { ui.label(RichText::new(h).strong()); } ui.end_row(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4de8ffe19..058ac44ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -21,6 +21,11 @@ impl Dialog { pub fn new(id: &str) -> Self { Dialog { id: id.into(), params: Value::Null, fields: Default::default() } } + pub(crate) fn preferences(app: &SlideApp) -> Self { + let mut d = Self::new("preferences"); + d.params = json!({ "before": preferences_snapshot(app) }); + d + } pub fn modal(&self) -> bool { true } @@ -29,8 +34,8 @@ impl Dialog { } } -fn title(id: &str) -> &'static str { - match id { +fn title(id: &str) -> String { + let raw = match id { "table" => "Insert Table", "slideSize" => "Slide Size", "sizePosition" => "Size and Position", @@ -74,7 +79,8 @@ fn title(id: &str) -> &'static str { "themeColors" => "Create New Theme Colors", "themeFonts" => "Create New Theme Fonts", _ => "DeckCraft", - } + }; + crate::i18n::t(raw).to_owned() } /// Render the active dialog, clearing it when dismissed and preserving or restoring application @@ -147,12 +153,18 @@ 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::t(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::tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { cancel = true; } }); @@ -164,12 +176,13 @@ fn preferences_snapshot(app: &SlideApp) -> Value { json!({ "dark": app.ui.brightness == theme::Brightness::Dark, "uiScale": app.ui.ui_scale, + "language": app.ui.language, "prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null), }) } /// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button). -fn restore_preferences(app: &mut SlideApp, d: &Dialog) { +pub(crate) fn restore_preferences(app: &mut SlideApp, d: &Dialog) { let Some(before) = d.params.get("before") else { return }; if let Some(dark) = before.get("dark").and_then(Value::as_bool) && dark != (app.ui.brightness == theme::Brightness::Dark) @@ -179,6 +192,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(lang) = before.get("language").and_then(Value::as_str) + && lang != app.ui.language + { + let _ = app.run("ui.language", json!({ "lang": lang })); + } if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) { app.session.prefs = prefs; } @@ -189,7 +207,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::t(label))); ui.add(egui::TextEdit::singleline(&mut v).desired_width(180.0)).labelled_by(l.id); }); d.fields.insert(key.into(), v); @@ -209,7 +227,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::t(label)); d.fields.insert(key.into(), if v { "1".into() } else { "0".into() }); v } @@ -259,9 +277,13 @@ fn store_album_items(d: &mut Dialog, items: &[Value]) { fn album_item_label(v: &Value) -> String { if let Some(text) = v.get("text").and_then(Value::as_str) { let text = text.trim(); - if text.is_empty() { "Text Box".to_string() } else { format!("Text: {text}") } + if text.is_empty() { + crate::tl!("Text Box").to_string() + } else { + format!("{}: {text}", crate::tl!("Text")) + } } else { - v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("Picture").to_string() + v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string).unwrap_or_else(|| crate::tl!("Picture").to_string()) } } @@ -269,7 +291,7 @@ fn pick_album_picture(app: &mut SlideApp) -> Option { let path = if let Some(pick) = app.services.pick_open.as_mut() { pick("picture") } else { - app.set_status("Pass pictures to insert.photoAlbum"); + app.set_status(crate::tl!("Pass pictures to insert.photoAlbum")); None }; let path = path?; @@ -294,16 +316,16 @@ fn pick_album_picture(app: &mut SlideApp) -> Option { } #[cfg(target_arch = "wasm32")] { - app.set_status("The web build inserts album pictures from data passed to insert.photoAlbum"); + app.set_status(crate::tl!("The web build inserts album pictures from data passed to insert.photoAlbum")); return None; } }; if loaded.is_empty() { - app.set_status("That file is empty"); + app.set_status(crate::tl!("That file is empty")); return None; } if loaded.len() > 32 * 1024 * 1024 { - app.set_status("That picture is larger than 32 MB"); + app.set_status(crate::tl!("That picture is larger than 32 MB")); return None; } let name = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).filter(|s| !s.is_empty()).unwrap_or("picture").to_string(); @@ -314,10 +336,11 @@ fn pick_album_picture(app: &mut SlideApp) -> Option { /// newly chosen key; the caller supplies the visible label. fn album_choice(ui: &mut Ui, d: &mut Dialog, id: &str, name: &str, key: &str, default: &str, choices: &[(&str, &str)]) { let cur = d.get(key, default); - let label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); + let raw_label = choices.iter().find(|(k, _)| *k == cur).map(|(_, l)| *l).unwrap_or(default); + let label = crate::i18n::t(raw_label); let opened = egui::ComboBox::from_id_salt(id).selected_text(label).width(220.0).show_ui(ui, |ui| { for (k, l) in choices { - if ui.selectable_label(cur == *k, *l).clicked() { + if ui.selectable_label(cur == *k, crate::i18n::t(l)).clicked() { d.fields.insert(key.into(), (*k).to_string()); } } @@ -400,13 +423,13 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.set_min_width(440.0); egui::ScrollArea::vertical().max_height(520.0).id_salt("album_body").show(ui, |ui| { ui.set_min_width(420.0); - ui.label("Pictures in album"); + ui.label(crate::tl!("Pictures in album")); let items = album_items(d).unwrap_or_default(); let mut sel = d.get("sel", "0").parse::().unwrap_or(0); // Fixed height so adding pictures does not push Create past the window. egui::ScrollArea::vertical().max_height(120.0).min_scrolled_height(120.0).auto_shrink([false, false]).id_salt("album_list").show(ui, |ui| { if items.is_empty() { - ui.label(egui::RichText::new("No pictures yet").color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(crate::tl!("No pictures yet")).color(Tokens::get(ui.ctx()).text_dim)); } for (i, item) in items.iter().enumerate() { if ui.selectable_label(sel == i, album_item_label(item)).clicked() { @@ -416,7 +439,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); d.fields.insert("sel".into(), sel.to_string()); ui.horizontal(|ui| { - if ui.button("Add Pictures…").clicked() + if ui.button(crate::tl!("Add Pictures…")).clicked() && let Some(item) = pick_album_picture(app) { match album_items(d) { @@ -426,20 +449,20 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { store_album_items(d, &list); d.fields.insert("sel".into(), next.to_string()); } - Some(_) => app.set_status("A photo album holds at most 48 pictures or text boxes"), - None => app.set_status("The picture list is invalid"), + Some(_) => app.set_status(crate::tl!("A photo album holds at most 48 pictures or text boxes")), + None => app.set_status(crate::tl!("The picture list is invalid")), } } - if ui.button("New Text Box").clicked() { + if ui.button(crate::tl!("New Text Box")).clicked() { let text = d.get("boxText", "Text Box"); let text = text.trim(); if text.is_empty() { - app.set_status("Type the text for the new text box"); + app.set_status(crate::tl!("Type the text for the new text box")); } else if text.chars().count() > 400 { - app.set_status("Text is longer than 400 characters"); + app.set_status(crate::tl!("Text is longer than 400 characters")); } else if let Some(mut list) = album_items(d) { if list.len() >= 48 { - app.set_status("A photo album holds at most 48 pictures or text boxes"); + app.set_status(crate::tl!("A photo album holds at most 48 pictures or text boxes")); } else { list.push(json!({"text": text})); let next = list.len().saturating_sub(1); @@ -448,7 +471,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } } - if ui.button("Remove").clicked() + if ui.button(crate::tl!("Remove")).clicked() && let Some(mut list) = album_items(d) && let Some(i) = d.get("sel", "0").parse::().ok() && i < list.len() @@ -464,36 +487,36 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.label(album_picture_status(&items, sel)); ui.horizontal_wrapped(|ui| { ui.add_enabled_ui(picture, |ui| { - if ui.button("Rotate Left").clicked() { + if ui.button(crate::tl!("Rotate Left")).clicked() { with_selected_picture(d, |obj| { let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); let next = ((cur - 90.0) % 360.0 + 360.0) % 360.0; obj.insert("rot".into(), json!(next)); }); } - if ui.button("Rotate Right").clicked() { + if ui.button(crate::tl!("Rotate Right")).clicked() { with_selected_picture(d, |obj| { let cur = obj.get("rot").and_then(Value::as_f64).unwrap_or(0.0); let next = ((cur + 90.0) % 360.0 + 360.0) % 360.0; obj.insert("rot".into(), json!(next)); }); } - if ui.button("More Brightness").clicked() { + if ui.button(crate::tl!("More Brightness")).clicked() { nudge_picture(d, "brightness", 0.1); } - if ui.button("Less Brightness").clicked() { + if ui.button(crate::tl!("Less Brightness")).clicked() { nudge_picture(d, "brightness", -0.1); } - if ui.button("More Contrast").clicked() { + if ui.button(crate::tl!("More Contrast")).clicked() { nudge_picture(d, "contrast", 0.1); } - if ui.button("Less Contrast").clicked() { + if ui.button(crate::tl!("Less Contrast")).clicked() { nudge_picture(d, "contrast", -0.1); } }); }); ui.add_space(4.0); - ui.label("Picture layout"); + ui.label(crate::tl!("Picture layout")); album_choice( ui, d, @@ -511,7 +534,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("4title", "4 pictures with title"), ], ); - ui.label("Frame shape"); + ui.label(crate::tl!("Frame shape")); album_choice( ui, d, @@ -523,7 +546,7 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ); check(ui, d, "Captions under pictures", "captions", false); check(ui, d, "ALL pictures black and white", "grayscale", false); - ui.label("Album theme"); + ui.label(crate::tl!("Album theme")); album_choice(ui, d, "album_theme", "Album theme", "theme", "DeckCraft", &ALBUM_THEMES.iter().map(|n| (*n, *n)).collect::>()); }); let captions = d.fields.get("captions").is_some_and(|v| v == "1"); @@ -532,11 +555,11 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, if editing { "Update" } else { "Create" }); if ok { let Some(list) = album_items(d) else { - app.set_status("The picture list is invalid"); + app.set_status(crate::tl!("The picture list is invalid")); return false; }; if list.is_empty() { - app.set_status("Add a picture or a text box first"); + app.set_status(crate::tl!("Add a picture or a text box first")); return false; } let layout = d.get("layout", "1"); @@ -565,15 +588,18 @@ fn photo_album_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { fn album_picture_status(items: &[Value], sel: usize) -> String { let Some(item) = items.get(sel) else { - return "Select a picture to rotate it or change brightness and contrast".into(); + return crate::tl!("Select a picture to rotate it or change brightness and contrast").to_string(); }; if item.get("data").is_none() && item.get("path").is_none() { - return "Text boxes have no picture options".into(); - } + return crate::tl!("Text boxes have no picture options").to_string(); + }; let rot = item.get("rot").and_then(Value::as_f64).unwrap_or(0.0); let brightness = item.get("brightness").and_then(Value::as_f64).unwrap_or(0.0); let contrast = item.get("contrast").and_then(Value::as_f64).unwrap_or(0.0); - format!("Rotation {rot:.0}°, brightness {brightness:.1}, contrast {contrast:.1}") + crate::i18n::tf( + "Rotation {rot}°, brightness {brightness}, contrast {contrast}", + &[("rot", &format!("{rot:.0}")), ("brightness", &format!("{brightness:.1}")), ("contrast", &format!("{contrast:.1}"))], + ) } fn nudge_picture(d: &mut Dialog, key: &str, delta: f64) { @@ -603,7 +629,7 @@ fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { let key = slot.xml_name(); let mut v = d.get(key, "000000"); ui.horizontal(|ui| { - ui.add_sized(vec2(188.0, 18.0), egui::Label::new(slot.label())); + ui.add_sized(vec2(188.0, 18.0), egui::Label::new(crate::i18n::t(slot.label()))); let fill = deckcraft_color::Rgba::from_hex(&v).map(theme::to_color32).unwrap_or_else(|| Color32::from_gray(200)); let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); ui.painter().rect(rect, CornerRadius::same(2), fill, Stroke::new(1.0, Tokens::get(ui.ctx()).border), egui::StrokeKind::Inside); @@ -621,13 +647,13 @@ fn theme_color_row(ui: &mut Ui, d: &mut Dialog, slot: SchemeSlot) { fn theme_color_payload(d: &mut Dialog) -> Result { let name = d.get("schemeName", "").trim().to_string(); if name.is_empty() { - return Err("Enter a scheme name.".into()); + return Err(crate::tl!("Enter a scheme name.").into()); } let mut colors = serde_json::Map::new(); for slot in SchemeSlot::THEME { let raw = d.get(slot.xml_name(), ""); let Some(rgb) = deckcraft_color::Rgba::from_hex(raw.trim()) else { - return Err(format!("{} needs a colour like #RRGGBB.", slot.label())); + return Err(crate::i18n::tf("{label} needs a colour like #RRGGBB.", &[("label", crate::i18n::t(slot.label()))])); }; colors.insert(slot.xml_name().into(), Value::String(format!("#{}", rgb.hex()))); } @@ -654,7 +680,7 @@ fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool ui.add_space(6.0); let mut name = d.get("schemeName", ""); ui.horizontal(|ui| { - ui.label("Name:"); + ui.label(crate::tl!("Name:")); let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(280.0).id_salt("schemeName")); let shown = name.clone(); resp.widget_info(move || { @@ -664,7 +690,7 @@ fn theme_colors_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool }); }); d.fields.insert("schemeName".into(), name); - ui.label(egui::RichText::new("Applies to this presentation's theme colours.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(crate::tl!("Applies to this presentation's theme colours.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim)); let (ok, cancel) = buttons(ui, "Save"); if ok { match theme_color_payload(d) { @@ -727,15 +753,15 @@ fn font_combo(ui: &mut Ui, label: &str, choices: &[String], selected: &mut Strin fn theme_font_payload(d: &mut Dialog) -> Result { let name = d.get("schemeName", "").trim().to_string(); if name.is_empty() { - return Err("Enter a scheme name.".into()); + return Err(crate::tl!("Enter a scheme name.").into()); } let major = d.get("major", "").trim().to_string(); if major.is_empty() { - return Err("Choose a heading font.".into()); + return Err(crate::tl!("Choose a heading font.").into()); } let minor = d.get("minor", "").trim().to_string(); if minor.is_empty() { - return Err("Choose a body font.".into()); + return Err(crate::tl!("Choose a body font.").into()); } Ok(json!({"name": name, "major": major, "minor": minor})) } @@ -750,11 +776,11 @@ fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut major = major; let mut minor = minor; ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Heading:")); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(crate::tl!("Heading:"))); font_combo(ui, "Heading font", &headings, &mut major); }); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Body:")); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(crate::tl!("Body:"))); font_combo(ui, "Body font", &bodies, &mut minor); }); d.fields.insert("major".into(), major.clone()); @@ -762,12 +788,12 @@ fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.add_space(4.0); // Theme faces are not egui families; naming an unbound family panics. The lines show the // chosen names at heading and body sizes. - ui.label(egui::RichText::new(format!("Heading sample {major}")).size(20.0)); - ui.label(egui::RichText::new(format!("Body sample {minor}")).size(14.0)); + ui.label(egui::RichText::new(format!("{} {major}", crate::tl!("Heading sample"))).size(20.0)); + ui.label(egui::RichText::new(format!("{} {minor}", crate::tl!("Body sample"))).size(14.0)); ui.add_space(4.0); let mut name = d.get("schemeName", ""); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 18.0), egui::Label::new("Name:")); + ui.add_sized(vec2(110.0, 18.0), egui::Label::new(crate::tl!("Name:"))); let resp = ui.add(egui::TextEdit::singleline(&mut name).desired_width(240.0).id_salt("schemeName")); let shown = name.clone(); resp.widget_info(move || { @@ -777,7 +803,7 @@ fn theme_fonts_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); }); d.fields.insert("schemeName".into(), name); - ui.label(egui::RichText::new("Heading and body fonts for this presentation.").size(11.0).color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(crate::tl!("Heading and body fonts for this presentation.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim)); let (ok, cancel) = buttons(ui, "Save"); if ok { match theme_font_payload(d) { @@ -994,8 +1020,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::tl!("Zoom to")); + if ui.radio(mode == "fit", crate::tl!("Fit")).clicked() { mode = "fit".into(); } for pct in ZOOM_PRESETS { @@ -1006,7 +1032,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::tl!("Percent:")); let edit = ui.add(egui::TextEdit::singleline(&mut percent).desired_width(72.0)).labelled_by(l.id); if edit.changed() { mode = "percent".into(); @@ -1024,7 +1050,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::tl!("Enter a zoom percent")); return false; } } @@ -1110,17 +1136,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::tl!("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(if preset.is_empty() { crate::tl!("Custom") } else { crate::i18n::t(&preset) }) + .show_ui(ui, |ui| { + for (label, w, h) in deckcraft_model::defaults::SLIDE_SIZES { + if ui.selectable_label(preset == *label, crate::i18n::t(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")); @@ -1128,9 +1156,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { field(ui, d, &format!("Height ({abbr}):"), "h", &h_seed); let (w, h) = (slide_dim_points(d, "w", ppu, cur.width), slide_dim_points(d, "h", ppu, cur.height)); ui.horizontal(|ui| { - ui.label("Orientation:"); - let portrait = ui.radio(h > w, "Portrait").clicked(); - let landscape = ui.radio(w >= h, "Landscape").clicked(); + ui.label(crate::tl!("Orientation:")); + let portrait = ui.radio(h > w, crate::tl!("Portrait")).clicked(); + let landscape = ui.radio(w >= h, crate::tl!("Landscape")).clicked(); // Picking the other orientation swaps the width and height fields. if portrait && w > h || landscape && h > w { put_slide_dim(d, "w", h, ppu); @@ -1177,9 +1205,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::tl!("Apply to All")).clicked(); + apply = ui.button(crate::tl!("Apply")).clicked(); + if ui.button(crate::tl!("Cancel")).clicked() { d.fields.insert("cancel".into(), "1".into()); } }); @@ -1208,13 +1236,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::tl!("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::tl!("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::tl!("Email Address")).clicked() { d.fields.insert("mode".into(), "email".into()); } }); @@ -1254,13 +1282,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::tl!("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::t(l)).clicked() { d.fields.insert("type".into(), v.into()); } } @@ -1270,10 +1298,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let ut = check(ui, d, "Use timings, if present", "ut", sh.use_timings); let names: Vec = app.session.active().map(|s| s.doc.custom_shows.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); let chosen = d.fields.entry("custom".into()).or_insert_with(|| sh.custom_show.clone().unwrap_or_default()).clone(); - let field = ui.label("Slides"); - let label = if chosen.is_empty() { "All slides".to_string() } else { chosen.clone() }; + let field = ui.label(crate::tl!("Slides")); + let label = if chosen.is_empty() { crate::tl!("All slides").to_string() } else { chosen.clone() }; let combo = egui::ComboBox::from_id_salt("setup_custom").selected_text(label).show_ui(ui, |ui| { - if ui.selectable_label(chosen.is_empty(), "All slides").clicked() { + if ui.selectable_label(chosen.is_empty(), crate::tl!("All slides")).clicked() { d.fields.insert("custom".into(), String::new()); } for n in &names { @@ -1298,21 +1326,27 @@ 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::tf("{n} presentations", &[("n", &names.len().to_string())]) + }; + ui.label(egui::RichText::new(crate::i18n::tf("Do you want to save the changes you made to {what}?", &[("what", &what)])).strong()); + ui.label(crate::tl!("Your changes will be lost if you don't save them.")); ui.add_space(8.0); let mut done = false; ui.horizontal(|ui| { let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + if ui.add(egui::Button::new(crate::tl!("Don't Save")).min_size(vec2(84.0, 24.0))).clicked() { let _ = app.session.execute("file.recovery.discard", &json!({})); app.quit_confirmed = true; ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); done = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + let save = ui.add( + egui::Button::new(egui::RichText::new(crate::tl!("Save")).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)), + ); if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { if app.save_all() { app.quit_confirmed = true; @@ -1320,7 +1354,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::tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { done = true; } }); @@ -1330,18 +1364,26 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { "close" => { // File › Close and Ctrl+W on a presentation with unsaved changes. let Some(name) = app.session.active().map(|d| d.title()) else { return true }; - ui.label(egui::RichText::new(format!("Do you want to save the changes you made to “{name}”?")).strong()); - ui.label("Your changes will be lost if you don't save them."); + ui.label( + egui::RichText::new(crate::i18n::tf( + "Do you want to save the changes you made to {what}?", + &[("what", &format!("“{name}”"))], + )) + .strong(), + ); + ui.label(crate::tl!("Your changes will be lost if you don't save them.")); ui.add_space(8.0); let mut done = false; ui.horizontal(|ui| { let t = Tokens::get(ui.ctx()); - if ui.add(egui::Button::new("Don't Save").min_size(vec2(84.0, 24.0))).clicked() { + if ui.add(egui::Button::new(crate::tl!("Don't Save")).min_size(vec2(84.0, 24.0))).clicked() { run(app, "file.close", json!({})); done = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0))); + let save = ui.add( + egui::Button::new(egui::RichText::new(crate::tl!("Save")).color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)), + ); if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { app.save(); // In the browser a save is a download: the presentation stays "unsaved". @@ -1351,7 +1393,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::tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() { done = true; } }); @@ -1361,7 +1403,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::tl!("Comment")).desired_rows(4).desired_width(320.0)); d.fields.insert("text".into(), v.clone()); let (ok, cancel) = buttons(ui, "Post"); if ok && !v.trim().is_empty() { @@ -1377,7 +1419,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let tab = d.get("tab", if numbered { "numbered" } else { "bulleted" }); ui.horizontal(|ui| { for (k, l) in [("bulleted", "Bulleted"), ("numbered", "Numbered")] { - if ui.selectable_label(tab == k, l).clicked() { + if ui.selectable_label(tab == k, crate::i18n::t(l)).clicked() { d.fields.insert("tab".into(), k.into()); } } @@ -1430,12 +1472,12 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ]; let color = d.get("color", ""); ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Color:")); + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(crate::tl!("Color:"))); let combo = egui::ComboBox::from_id_salt("bullet-color") - .selected_text(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1)) + .selected_text(crate::i18n::t(colors.iter().find(|c| c.0 == color).map_or("Automatic", |c| c.1))) .show_ui(ui, |ui| { for (k, l) in colors { - if ui.selectable_label(color == k, l).clicked() { + if ui.selectable_label(color == k, crate::i18n::t(l)).clicked() { d.fields.insert("color".into(), k.into()); } } @@ -1443,7 +1485,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = combo.response.labelled_by(field.id); }); ui.horizontal(|ui| { - if ui.button("None").on_hover_text("Remove bullets and numbering").clicked() { + if ui.button(crate::tl!("None")).on_hover_text(crate::tl!("Remove bullets and numbering")).clicked() { d.fields.insert("none".into(), "1".into()); } }); @@ -1503,24 +1545,24 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }; ui.horizontal(|ui| { for (k, l) in [("click", "Mouse Click"), ("hover", "Mouse Over")] { - if ui.selectable_label(tab == k, l).clicked() && tab != k { + if ui.selectable_label(tab == k, crate::i18n::t(l)).clicked() && tab != k { d.fields.clear(); d.fields.insert("tab".into(), k.into()); } } }); ui.separator(); - ui.label(if tab == "hover" { "Action on mouse over" } else { "Action on click" }); + ui.label(if tab == "hover" { crate::tl!("Action on mouse over") } else { crate::tl!("Action on click") }); let kind = d.get("kind", cur_kind); let hyperlink = !matches!(kind.as_str(), "none" | "program" | "playMedia"); let pick = |d: &mut Dialog, ui: &mut Ui, k: &str, l: &str| { - if ui.radio(d.get("kind", cur_kind) == k, l).clicked() { + if ui.radio(d.get("kind", cur_kind) == k, crate::i18n::t(l)).clicked() { d.fields.insert("kind".into(), k.into()); } }; pick(d, ui, "none", "None"); ui.horizontal(|ui| { - let field = ui.radio(hyperlink, "Hyperlink to:"); + let field = ui.radio(hyperlink, crate::tl!("Hyperlink to:")); if field.clicked() && !hyperlink { d.fields.insert("kind".into(), "next".into()); } @@ -1535,11 +1577,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("slide", "Slide…"), ("url", "URL…"), ]; - let shown = targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1); + let shown = crate::i18n::t(targets.iter().find(|t| t.0 == kind).map_or("Next Slide", |t| t.1)); ui.add_enabled_ui(hyperlink, |ui| { let combo = egui::ComboBox::from_id_salt("action-target").selected_text(shown).show_ui(ui, |ui| { for (k, l) in targets { - if ui.selectable_label(kind == k, l).clicked() { + if ui.selectable_label(kind == k, crate::i18n::t(l)).clicked() { d.fields.insert("kind".into(), k.into()); } } @@ -1562,7 +1604,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "customShow" => { if shows.is_empty() { - ui.label("This presentation has no custom shows (Slide Show ▸ Custom Slide Show)."); + ui.label(crate::tl!("This presentation has no custom shows (Slide Show ▸ Custom Slide Show).")); } let sel = d.get("arg", if cur_kind == "customShow" { &cur_arg } else { shows.first().map_or("", String::as_str) }); for n in &shows { @@ -1606,7 +1648,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::tl!("How would you describe this object and its context to someone who is blind or has low vision?")); let mut v = d.get("text", &cur); ui.add(egui::TextEdit::multiline(&mut v).hint_text("Alt text").desired_rows(4).desired_width(340.0)); d.fields.insert("text".into(), v.clone()); @@ -1618,7 +1660,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::tl!("Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets.")); let mut v = d.get("text", ""); ui.add(egui::TextEdit::multiline(&mut v).hint_text("Outline").desired_rows(10).desired_width(380.0).code_editor()); d.fields.insert("text".into(), v.clone()); @@ -1708,7 +1750,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::tl!("Export the presentation as:")); let fmt = d.get("fmt", "pdf"); for (l, f) in [ ("PDF document (.pdf)", "pdf"), @@ -1720,7 +1762,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::t(l)).clicked() { d.fields.insert("fmt".into(), f.into()); } } @@ -1728,9 +1770,9 @@ 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::tl!("Print layout:")); let combo = egui::ComboBox::from_id_salt("pdf_layout") - .selected_text(match pdf_layout.as_str() { + .selected_text(crate::i18n::t(match pdf_layout.as_str() { "notes" => "Notes Pages", "h1" => "Handouts (1 slide per page)", "h2" => "Handouts (2 slides per page)", @@ -1739,7 +1781,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)", _ => "Full Page Slides", - }) + })) .show_ui(ui, |ui| { for (k, l) in [ ("slides", "Full Page Slides"), @@ -1751,7 +1793,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::t(l)).clicked() { d.fields.insert("pdfLayout".into(), k.into()); } } @@ -1759,7 +1801,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::tl!("Include hidden slides")).changed() { d.fields.insert("pdfHidden".into(), hidden.to_string()); } } @@ -1768,8 +1810,8 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.add_space(4.0); let mut clear = d.get("pngTransparent", "false") == "true"; if ui - .checkbox(&mut clear, "Transparent background") - .on_hover_text("Leave the slide background out: only what is on the slide is drawn") + .checkbox(&mut clear, crate::tl!("Transparent background")) + .on_hover_text(crate::tl!("Leave the slide background out: only what is on the slide is drawn")) .changed() { d.fields.insert("pngTransparent".into(), clear.to_string()); @@ -1813,7 +1855,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut from = d.get("from", used.first().map(String::as_str).unwrap_or("")); let mut to = d.get("to", ""); egui::Grid::new("replace_fonts").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { - let field = ui.label("Replace:"); + let field = ui.label(crate::tl!("Replace:")); let combo = egui::ComboBox::from_id_salt("rf_from").width(240.0).selected_text(from.clone()).show_ui(ui, |ui| { for f in &used { ui.selectable_value(&mut from, f.clone(), f); @@ -1821,9 +1863,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); let _ = combo.response.labelled_by(field.id); ui.end_row(); - ui.label("With:"); + ui.label(crate::tl!("With:")); ui.vertical(|ui| { - ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text("Font name")); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(240.0).hint_text(crate::tl!("Font name"))); let needle = to.trim().to_lowercase(); egui::ScrollArea::vertical().max_height(140.0).id_salt("rf_list").show(ui, |ui| { for f in @@ -1845,8 +1887,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, cancel) = buttons(ui, "Replace"); if ok && !from.is_empty() && !to.trim().is_empty() { let n = app.run("font.replace", json!({"from": from, "to": to.trim()})).ok().and_then(|v| v["replaced"].as_u64()).unwrap_or(0); - let msg = - if n == 0 { format!("“{from}” isn't used explicitly.") } else { format!("Replaced “{from}” with “{}”.", to.trim()) }; + let msg = if n == 0 { + crate::i18n::tf("“{from}” isn't used explicitly.", &[("from", &from)]) + } else { + crate::i18n::tf("Replaced “{from}” with “{to}”.", &[("from", &from), ("to", to.trim())]) + }; d.fields.insert("result".into(), msg); d.fields.remove("from"); // Stay open, like PowerPoint, so several fonts can be replaced in a row. @@ -1855,7 +1900,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::tl!("Click a symbol to insert it at the insertion point.")); let groups: [(&str, &str); 6] = [ ("Punctuation", "–—‘’“”…•·§¶†‡‰′″‹›«»¡¿"), ("Currency", "$€£¥¢₹₩₽₺₿"), @@ -1865,7 +1910,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::t(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 +1926,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::tl!("Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a).")); let mut v = d.get("eq", ""); ui.add(egui::TextEdit::singleline(&mut v).hint_text("Equation").desired_width(340.0).font(theme::font(15.0))); d.fields.insert("eq".into(), v.clone()); @@ -1906,10 +1951,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ]; let has_shapes = !app.session.clipboard.shapes.is_empty(); let mut cur = d.get("as", "destinationTheme"); - ui.label("As:"); + ui.label(crate::tl!("As:")); for (id, label) in OPTS { let ok = has_shapes || matches!(id, "destinationTheme" | "text"); - ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), label)); + ui.add_enabled_ui(ok, |ui| ui.radio_value(&mut cur, id.to_string(), crate::i18n::t(label))); } d.fields.insert("as".into(), cur.clone()); let (ok, cancel) = buttons(ui, "OK"); @@ -1928,28 +1973,35 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let info = app.session.execute("review.dictionary", &json!({})).unwrap_or_default(); let available = info.get("available").and_then(Value::as_bool).unwrap_or(false); match info.get("found").and_then(Value::as_str) { - Some(f) if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", f.rsplit(['/', '\\']).next().unwrap_or(f))).small().weak()) - } - _ if available => { - ui.label(egui::RichText::new(format!("Dictionary: {}", info.get("lang").and_then(Value::as_str).unwrap_or(""))).small().weak()) - } + Some(f) if available => ui.label( + egui::RichText::new(crate::i18n::tf("Dictionary: {name}", &[("name", f.rsplit(['/', '\\']).next().unwrap_or(f))])).small().weak(), + ), + _ if available => ui.label( + egui::RichText::new(crate::i18n::tf("Dictionary: {name}", &[("name", info.get("lang").and_then(Value::as_str).unwrap_or(""))])) + .small() + .weak(), + ), _ => ui.label( - egui::RichText::new(format!( - "No {} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {}.", - info.get("lang").and_then(Value::as_str).unwrap_or("en-US"), - info.get("folders") - .and_then(Value::as_array) - .and_then(|a| a.get(1).or(a.first())) - .and_then(Value::as_str) - .unwrap_or("a dictionaries folder") + egui::RichText::new(crate::i18n::tf( + "No {lang} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {folder}.", + &[ + ("lang", info.get("lang").and_then(Value::as_str).unwrap_or("en-US")), + ( + "folder", + info.get("folders") + .and_then(Value::as_array) + .and_then(|a| a.get(1).or(a.first())) + .and_then(Value::as_str) + .unwrap_or("a dictionaries folder"), + ), + ], )) .small() .weak(), ), }; if list.is_empty() { - ui.label("The spelling check is complete. No issues found."); + ui.label(crate::tl!("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 +2010,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(format!("{} {}: “{w}”", crate::tl!("Slide"), slide.as_u64().unwrap_or(0) + 1)); + if ui.small_button(crate::tl!("Go to")).clicked() { let _ = app.run("slide.go", json!({"index": slide})); let _ = app.run("edit.select", json!({"ids": [shape]})); } - if ui.small_button("Ignore All").clicked() { + if ui.small_button(crate::tl!("Ignore All")).clicked() { add_ignore = Some(w.to_string()); } - if ui.small_button("Add to Dictionary").clicked() + if ui.small_button(crate::tl!("Add to Dictionary")).clicked() && let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w})) { app.set_status(e.to_string()); @@ -1981,8 +2033,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::tl!("Repeated word")).weak()); + if ui.small_button(crate::tl!("Delete Repeated Word")).clicked() { let from = item.get("deleteFrom").and_then(Value::as_u64).unwrap_or(0) as usize; let end = item.get("end").and_then(Value::as_u64).unwrap_or(0) as usize; let para = item.get("para").and_then(Value::as_u64).unwrap_or(0) as usize; @@ -2010,9 +2062,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { if !sug.is_empty() { ui.horizontal_wrapped(|ui| { ui.add_space(16.0); - ui.label(egui::RichText::new("Change to:").weak()); + ui.label(egui::RichText::new(crate::tl!("Change to:")).weak()); for to in &sug { - let r = ui.small_button(to.as_str()).on_hover_text("Click to change this one; Shift-click to change all"); + let r = ui + .small_button(to.as_str()) + .on_hover_text(crate::tl!("Click to change this one; Shift-click to change all")); if r.clicked() { let all = ui.input(|i| i.modifiers.shift); if item.get("notes").and_then(Value::as_bool).unwrap_or(false) { @@ -2069,18 +2123,19 @@ 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::tl!("No accessibility issues found. People with disabilities should not have difficulty reading this document.")); } egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for item in &list { ui.horizontal(|ui| { ui.label(format!( - "Slide {}: {}{}", + "{} {}: {}{}", + crate::tl!("Slide"), item.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1, - item.get("issue").and_then(Value::as_str).unwrap_or(""), + crate::i18n::t(item.get("issue").and_then(Value::as_str).unwrap_or("")), item.get("name").and_then(Value::as_str).map(|n| format!(" — {n}")).unwrap_or_default() )); - if ui.small_button("Go to").clicked() { + if ui.small_button(crate::tl!("Go to")).clicked() { let _ = app.run("slide.go", json!({"index": item.get("slide").cloned().unwrap_or_default()})); if let Some(id) = item.get("shape") { let _ = app.run("edit.select", json!({"ids": [id]})); @@ -2101,17 +2156,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::tl!("Apply to:")); + if ui.add_enabled(has_sel, egui::RadioButton::new(!all, crate::tl!("Selected text"))).clicked() { d.fields.insert("scope".into(), "sel".into()); all = false; } - if ui.radio(all, "Whole presentation").clicked() { + if ui.radio(all, crate::tl!("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(if all { crate::tl!("Mark all text in the presentation as:") } else { crate::tl!("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 +2176,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 t_name = crate::i18n::t(&name); + let label = if installed { format!("{t_name} ({})", crate::tl!("dictionary")) } else { t_name.to_string() }; 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::tl!("Spelling is checked for languages marked (dictionary).")).small().weak()); + if ui.checkbox(&mut no_proof, crate::tl!("Do not check spelling or grammar")).changed() { d.fields.insert("noProof".into(), if no_proof { "1".into() } else { String::new() }); } let (ok, cancel) = buttons(ui, "OK"); @@ -2144,12 +2200,12 @@ 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(crate::i18n::tf("{name} ({n} slides)", &[("name", &s.name), ("n", &s.slides.len().to_string())])); + if ui.small_button(crate::tl!("Show")).clicked() { run(app, "show.start", json!({"custom": s.name})); started = true; } - if ui.small_button("Delete").clicked() { + if ui.small_button(crate::tl!("Delete")).clicked() { let _ = app.run("show.customShow", json!({"name": s.name, "delete": true})); } }); @@ -2174,11 +2230,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::tl!("Select a chart first.")); let (ok, cancel) = buttons(ui, "Close"); return ok || cancel; }; - ui.label("Edit the data: first row = series names, first column = categories."); + ui.label(crate::tl!("Edit the data: first row = series names, first column = categories.")); let mut grid: Vec> = d.params.get("grid").and_then(|g| serde_json::from_value(g.clone()).ok()).unwrap_or_else(|| { let mut g = vec![std::iter::once(String::new()).chain(c.series.iter().map(|s| s.name.clone())).collect::>()]; for (i, cat) in c.categories.iter().enumerate() { @@ -2199,11 +2255,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::tl!("+ Row")).clicked() { let n = grid.first().map(Vec::len).unwrap_or(2); grid.push(vec![String::new(); n]); } - if ui.button("+ Series").clicked() { + if ui.button(crate::tl!("+ Series")).clicked() { for r in grid.iter_mut() { r.push(String::new()); } @@ -2241,7 +2297,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::tl!("Pick a graphic; type one item per line.")); let kind = d.get("kind", "process"); ui.horizontal_wrapped(|ui| { for (k, l) in [ @@ -2252,7 +2308,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::t(l)).clicked() { d.fields.insert("kind".into(), k.into()); } } @@ -2271,7 +2327,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::t(l)).clicked() { d.fields.insert("tab".into(), k.into()); } } @@ -2288,23 +2344,23 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "autocorrect" => { let pr = &mut app.session.prefs; - ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); - ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); - ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); - ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); - ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); - ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); - ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); - ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); + ui.checkbox(&mut pr.ac_two_initials, crate::tl!("Correct TWo INitial CApitals")); + ui.checkbox(&mut pr.ac_sentence_caps, crate::tl!("Capitalize first letter of sentences")); + ui.checkbox(&mut pr.ac_days, crate::tl!("Capitalize names of days")); + ui.checkbox(&mut pr.ac_caps_lock, crate::tl!("Correct accidental use of cAPS LOCK key")); + ui.checkbox(&mut pr.ac_fractions, crate::tl!("Replace fractions (1/2) with fraction characters (½)")); + ui.checkbox(&mut pr.ac_ordinals, crate::tl!("Ordinals (1st) with superscript")); + ui.checkbox(&mut pr.ac_lists, crate::tl!("Automatic bulleted and numbered lists")); + ui.checkbox(&mut pr.autocorrect, crate::tl!("Replace text as you type")); ui.add_space(4.0); let mut from = d.get("from", ""); let mut to = d.get("to", ""); ui.horizontal(|ui| { - let l = ui.label("Replace"); + let l = ui.label(crate::tl!("Replace")); ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)).labelled_by(l.id); - let l = ui.label("With"); + let l = ui.label(crate::tl!("With")); ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)).labelled_by(l.id); - if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() { run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); from.clear(); to.clear(); @@ -2320,7 +2376,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { ui.label(k); ui.label(v); - if ui.small_button("Delete").clicked() { + if ui.small_button(crate::tl!("Delete")).clicked() { run(app, "text.autocorrect", json!({"remove": k})); } ui.end_row(); @@ -2338,11 +2394,27 @@ 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.label(egui::RichText::new(crate::tl!("General")).font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; - if ui.checkbox(&mut dark, "Dark appearance").changed() { + if ui.checkbox(&mut dark, crate::tl!("Dark appearance")).changed() { let _ = app.run("view.dark", json!({"on": dark})); } + ui.horizontal(|ui| { + let field = ui.label(crate::tl!("Language:")); + let lang_label = |code: &str| match code { + "en" => "English", + "vi" => "Tiếng Việt", + _ => crate::tl!("System Default"), + }; + let combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang_label(&app.ui.language)).show_ui(ui, |ui| { + for code in ["auto", "en", "vi"] { + if ui.selectable_label(app.ui.language == code, lang_label(code)).clicked() { + let _ = app.run("ui.language", json!({"lang": code})); + } + } + }); + let _ = combo.response.labelled_by(field.id); + }); // While the pointer holds the slider its value only waits in the dialog: applying it // would rescale the slider under the pointer and run it to an end (#767). It applies // on release, and at once from the keyboard. @@ -2351,7 +2423,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::tl!("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 +2436,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::tl!("Measurement units:")); let units = &mut app.session.prefs.units; - ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches"); - ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters"); + ui.radio_value(units, deckcraft_geom::Unit::Inches, crate::tl!("Inches")); + ui.radio_value(units, deckcraft_geom::Unit::Centimeters, crate::tl!("Centimeters")); }); - ui.label(egui::RichText::new("Editing").font(theme::bold(13.0))); - ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); - ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); - ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); + ui.label(egui::RichText::new(crate::tl!("Editing")).font(theme::bold(13.0))); + ui.checkbox(&mut app.session.prefs.smart_guides, crate::tl!("Show smart guides")); + ui.checkbox(&mut app.session.prefs.snap_to_grid, crate::tl!("Snap objects to grid")); + ui.checkbox(&mut app.session.prefs.smart_quotes, crate::tl!("Replace straight quotes with smart quotes")); ui.horizontal(|ui| { - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); - if ui.button("AutoCorrect Options…").clicked() { + ui.checkbox(&mut app.session.prefs.autocorrect, crate::tl!("AutoCorrect as you type")); + if ui.button(crate::tl!("AutoCorrect Options…")).clicked() { *d = Dialog::new("autocorrect"); } }); @@ -2383,7 +2455,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::tl!("Your name (comments):")); // Name the field for assistive technology; a bare text edit next to a label is an // unnamed input in egui 0.37's accessibility tree. let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id); @@ -2399,7 +2471,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::tf("“{id}” is not available yet.", &[("id", &d.id)])); let (ok, cancel) = buttons(ui, "OK"); ok || cancel } @@ -2563,17 +2635,17 @@ fn reconcile_size_fields(d: &mut Dialog) { fn shown_points(d: &Dialog, key: &str, label: &str) -> Result { if edited(d, key) { - let n = parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"))?; + let n = parse_shown(&field_of(d, key)).ok_or_else(|| crate::i18n::tf("{label} must be a number", &[("label", crate::i18n::t(label))]))?; return Ok(n * 72.0); } - exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) + exact_of(d, key).ok_or_else(|| crate::i18n::tf("{label} must be a number", &[("label", crate::i18n::t(label))])) } fn shown_number(d: &Dialog, key: &str, label: &str) -> Result { if edited(d, key) { - return parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number")); + return parse_shown(&field_of(d, key)).ok_or_else(|| crate::i18n::tf("{label} must be a number", &[("label", crate::i18n::t(label))])); } - exact_of(d, key).ok_or_else(|| format!("{label} must be a number")) + exact_of(d, key).ok_or_else(|| crate::i18n::tf("{label} must be a number", &[("label", crate::i18n::t(label))])) } fn size_position_value(d: &Dialog) -> Result { @@ -2612,7 +2684,7 @@ fn size_position_value(d: &Dialog) -> Result { fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { let mut v = field_of(d, key); ui.horizontal(|ui| { - let l = ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label)); + let l = ui.add_sized(vec2(148.0, 20.0), egui::Label::new(crate::i18n::t(label))); ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0)).labelled_by(l.id); }); d.fields.insert(key.into(), v); @@ -2620,7 +2692,7 @@ fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) { fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { seed_size_position(app, d); - ui.label(egui::RichText::new("Size").strong()); + ui.label(egui::RichText::new(crate::tl!("Size")).strong()); text_row(ui, d, "Height (in):", "h"); text_row(ui, d, "Width (in):", "w"); text_row(ui, d, "Rotation (°):", "rot"); @@ -2628,8 +2700,8 @@ fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool text_row(ui, d, "Scale width (%):", "scaleW"); check(ui, d, "Lock aspect ratio", "lock", false); ui.add_space(8.0); - ui.label(egui::RichText::new("Position").strong()); - ui.label("From the top-left corner of the slide"); + ui.label(egui::RichText::new(crate::tl!("Position")).strong()); + ui.label(crate::tl!("From the top-left corner of the slide")); text_row(ui, d, "Horizontal (in):", "x"); text_row(ui, d, "Vertical (in):", "y"); reconcile_size_fields(d); @@ -2678,7 +2750,7 @@ fn date_rows(lang: &str) -> Vec<(&'static str, String)> { /// whether the dialog should close. fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.set_min_width(440.0); - ui.label("Available formats:"); + ui.label(crate::tl!("Available formats:")); let lang = d.get("language", "en-US"); let rows = date_rows(&lang); if !rows.iter().any(|(k, _)| *k == d.get("format", "datetime1")) @@ -2694,12 +2766,13 @@ fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } }); - let lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); + let raw_lang_label = DATE_LANGS.iter().find(|(k, _)| *k == lang).map(|(_, l)| *l).unwrap_or("English (United States)"); + let lang_label = crate::i18n::t(raw_lang_label); ui.horizontal(|ui| { - ui.label("Language:"); + ui.label(crate::tl!("Language:")); let opened = egui::ComboBox::from_id_salt("date-lang").selected_text(lang_label).width(240.0).show_ui(ui, |ui| { for (k, l) in DATE_LANGS { - if ui.selectable_label(lang == *k, *l).clicked() { + if ui.selectable_label(lang == *k, crate::i18n::t(l)).clicked() { d.fields.insert("language".into(), (*k).to_string()); } } @@ -2712,9 +2785,11 @@ fn date_time_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); }); ui.label( - egui::RichText::new("Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time.") - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), + egui::RichText::new(crate::tl!( + "Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time." + )) + .size(11.0) + .color(Tokens::get(ui.ctx()).text_dim), ); let update = check(ui, d, "Update automatically", "update", true); let (ok, cancel) = buttons(ui, "OK"); @@ -2740,13 +2815,13 @@ fn format_spacing(v: f64) -> String { /// Grid Options: spacing, snap and whether the grid and guides are drawn. OK applies one /// `view.gridOptions` call; Cancel leaves the presentation as it was. fn grid_options(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { - ui.label(egui::RichText::new("Snap").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("Snap")).font(theme::bold(13.0))); let snap = check(ui, d, "Snap objects to grid", "snap", app.session.prefs.snap_to_grid); let smart = check(ui, d, "Snap objects to other objects", "smart", app.session.prefs.smart_guides); let spacing_default = format_spacing(app.session.prefs.grid_spacing); field(ui, d, "Spacing (pt):", "spacing", &spacing_default); ui.add_space(6.0); - ui.label(egui::RichText::new("Display").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("Display")).font(theme::bold(13.0))); let gridlines = check(ui, d, "Display grid on screen", "gridlines", app.ui.gridlines); let guides = check(ui, d, "Display drawing guides on screen", "guides", app.ui.guides); if let Some(err) = d.fields.get("error").cloned().filter(|s| !s.is_empty()) { @@ -2872,12 +2947,12 @@ pub fn about(_app: &mut SlideApp, ui: &mut Ui) { ui.label(egui::RichText::new("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::tl!("Presentations and slide shows, rebuilt from scratch in pure Rust.")); + ui.label(crate::tl!("An open-source, clean-room project. Not affiliated with Microsoft.")); ui.add_space(6.0); - ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD); + ui.hyperlink_to(crate::tl!("Join our community on Discord"), deckcraft_engine::links::DISCORD); ui.hyperlink_to("getartcraft.com/apps/deckcraft", deckcraft_engine::links::APP_PAGE); - ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB); + ui.hyperlink_to(crate::tl!("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)); }); @@ -2887,7 +2962,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 = format!("{} {}", crate::tl!("Version"), env!("CARGO_PKG_VERSION")); 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) { @@ -2982,10 +3057,10 @@ fn seed_font_fields(d: &mut Dialog) { /// the caller supplies the visible label. fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choices: &[(&str, &str)]) { let cur = d.get(key, ""); - let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); - let combo = egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| { + let raw_label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str()); + let combo = egui::ComboBox::from_id_salt(salt).selected_text(crate::i18n::t(raw_label)).width(140.0).show_ui(ui, |ui| { for (v, l) in choices { - if ui.selectable_label(cur == *v, *l).clicked() { + if ui.selectable_label(cur == *v, crate::i18n::t(l)).clicked() { d.fields.insert((*key).into(), (*v).into()); } } @@ -2995,7 +3070,7 @@ fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, name: &str, key: &str, choice fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String { let mut v = d.get(key, ""); - let l = ui.label(label); + let l = ui.label(crate::i18n::t(label)); ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id); d.fields.insert(key.into(), v.clone()); v @@ -3015,7 +3090,7 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { fams.insert(0, family.clone()); } ui.horizontal(|ui| { - let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font")); + let field = ui.add_sized(vec2(88.0, 20.0), egui::Label::new(crate::tl!("Font"))); let combo = egui::ComboBox::from_id_salt("font_dialog_family") .selected_text(if family.is_empty() { " " } else { family.as_str() }) .width(300.0) @@ -3037,7 +3112,7 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { check(ui, d, "Italic", "italic", false); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(crate::tl!("Underline"))); choice( ui, d, @@ -3050,34 +3125,34 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let _ = labeled_text(ui, d, "Color", "color", 110.0); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(crate::tl!("Strikethrough"))); choice(ui, d, "font_dialog_strike", "Strikethrough", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(crate::tl!("Baseline"))); choice(ui, d, "font_dialog_baseline", "Baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]); ui.add_space(8.0); - ui.label("Caps"); + ui.label(crate::tl!("Caps")); choice(ui, d, "font_dialog_caps", "Caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]); }); ui.horizontal(|ui| { - ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing")); + ui.add_sized(vec2(88.0, 20.0), egui::Label::new(crate::tl!("Spacing"))); let mut spacing = d.get("spacing", "0"); let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt")); let mut picked = None; let combo = egui::ComboBox::from_id_salt("font_dialog_spacing") .selected_text(match spacing.as_str() { - "-3" => "Very tight", - "-1.5" => "Tight", - "0" => "Normal", - "3" => "Loose", - "6" => "Very loose", + "-3" => crate::tl!("Very tight"), + "-1.5" => crate::tl!("Tight"), + "0" => crate::tl!("Normal"), + "3" => crate::tl!("Loose"), + "6" => crate::tl!("Very loose"), other => other, }) .width(120.0) .show_ui(ui, |ui| { for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] { - if ui.selectable_label(spacing == v, l).clicked() { + if ui.selectable_label(spacing == v, crate::i18n::t(l)).clicked() { picked = Some(v.to_string()); } } @@ -3115,15 +3190,15 @@ fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let family = d.get("family", ""); let family = family.trim(); if family.is_empty() { - app.set_status("Choose a font"); + app.set_status(crate::tl!("Choose a font")); return false; } let Some(size) = finite_pt(&d.get("size", "")) else { - app.set_status("Font size must be a number"); + app.set_status(crate::tl!("Font size must be a number")); return false; }; let Some(spacing) = finite_pt(&d.get("spacing", "0")) else { - app.set_status("Character spacing must be a number"); + app.set_status(crate::tl!("Character spacing must be a number")); return false; }; let mut params = json!({ @@ -3220,7 +3295,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.set_min_width(460.0); seed_paragraph_fields(d); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Alignment")); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(crate::tl!("Alignment"))); choice( ui, d, @@ -3237,7 +3312,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ], ); }); - ui.label(egui::RichText::new("Indentation").strong()); + ui.label(egui::RichText::new(crate::tl!("Indentation")).strong()); ui.horizontal(|ui| { let _ = labeled_text(ui, d, "Before text", "before", 64.0); ui.label("in"); @@ -3246,13 +3321,13 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.label("in"); }); ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Special")); + ui.add_sized(vec2(110.0, 20.0), egui::Label::new(crate::tl!("Special"))); choice(ui, d, "paragraph_special", "Special", "special", &[("none", "None"), ("first", "First line"), ("hanging", "Hanging")]); ui.add_space(8.0); let _ = labeled_text(ui, d, "By", "by", 64.0); ui.label("in"); }); - ui.label(egui::RichText::new("Spacing").strong()); + ui.label(egui::RichText::new(crate::tl!("Spacing")).strong()); ui.horizontal(|ui| { let _ = labeled_text(ui, d, "Spacing before", "spaceBefore", 64.0); ui.label("pt"); @@ -3262,10 +3337,10 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { }); let mut picked: Option<(&'static str, Option<&'static str>)> = None; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new("Line spacing")); + let field = ui.add_sized(vec2(110.0, 20.0), egui::Label::new(crate::tl!("Line spacing"))); let kind = d.get("lineKind", "multiple"); let value = d.get("lineValue", "1"); - let combo = egui::ComboBox::from_id_salt("paragraph_line").selected_text(line_preset_label(&kind, &value)).width(140.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt("paragraph_line").selected_text(crate::i18n::t(line_preset_label(&kind, &value))).width(140.0).show_ui(ui, |ui| { for (label, k, v) in [ ("Single", "multiple", Some("1")), ("1.5 lines", "multiple", Some("1.5")), @@ -3273,7 +3348,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ("Multiple", "multiple", None), ("Exactly", "exactly", None), ] { - if ui.selectable_label(line_preset_label(&kind, &value) == label, label).clicked() { + if ui.selectable_label(line_preset_label(&kind, &value) == label, crate::i18n::t(label)).clicked() { picked = Some((k, v)); } } @@ -3294,7 +3369,7 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } let mut open_tabs = false; ui.horizontal(|ui| { - if ui.button("Tabs…").clicked() { + if ui.button(crate::tl!("Tabs…")).clicked() { open_tabs = true; } }); @@ -3312,27 +3387,27 @@ fn paragraph_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } if ok { let Some(before_in) = finite_pt(&d.get("before", "")) else { - app.set_status("Indent before text must be a number of inches"); + app.set_status(crate::tl!("Indent before text must be a number of inches")); return false; }; let Some(after_in) = finite_pt(&d.get("after", "")) else { - app.set_status("Indent after text must be a number of inches"); + app.set_status(crate::tl!("Indent after text must be a number of inches")); return false; }; let Some(by_in) = finite_pt(&d.get("by", "")) else { - app.set_status("Special indent must be a number of inches"); + app.set_status(crate::tl!("Special indent must be a number of inches")); return false; }; let Some(space_before) = finite_pt(&d.get("spaceBefore", "")) else { - app.set_status("Spacing before must be a number"); + app.set_status(crate::tl!("Spacing before must be a number")); return false; }; let Some(space_after) = finite_pt(&d.get("spaceAfter", "")) else { - app.set_status("Spacing after must be a number"); + app.set_status(crate::tl!("Spacing after must be a number")); return false; }; let Some(line_value) = finite_pt(&d.get("lineValue", "")) else { - app.set_status("Line spacing must be a number"); + app.set_status(crate::tl!("Line spacing must be a number")); return false; }; let mut params = json!({ @@ -3384,17 +3459,21 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { .collect(); field(ui, d, "Default tab stops (in):", "default", "1"); ui.add_space(6.0); - ui.label("Tab stops:"); + ui.label(crate::tl!("Tab stops:")); let mut remove = None; egui::Grid::new("tab_stops").num_columns(3).spacing(vec2(12.0, 4.0)).show(ui, |ui| { if stops.is_empty() { - ui.label("(none)"); + ui.label(crate::tl!("(none)")); ui.end_row(); } for (i, (pos, align)) in stops.iter().enumerate() { ui.label(format!("{pos:.2}\"")); - ui.label(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left")); - if ui.small_button("Clear").on_hover_text(format!("Clear the tab at {pos:.2}\"")).clicked() { + ui.label(crate::i18n::t(TAB_KINDS.iter().find(|k| k.0 == align).map(|k| k.1).unwrap_or("Left"))); + if ui + .small_button(crate::tl!("Clear")) + .on_hover_text(crate::i18n::tf("Clear the tab at {pos}\"", &[("pos", &format!("{pos:.2}"))])) + .clicked() + { remove = Some(i); } ui.end_row(); @@ -3405,22 +3484,22 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ui.add_space(6.0); ui.horizontal(|ui| { - let l = ui.label("Tab stop position (in):"); + let l = ui.label(crate::tl!("Tab stop position (in):")); let mut v = d.get("newpos", ""); ui.add(egui::TextEdit::singleline(&mut v).desired_width(60.0)).labelled_by(l.id); d.fields.insert("newpos".into(), v); }); let cur = d.get("newalign", "l"); ui.horizontal(|ui| { - ui.label("Alignment:"); + ui.label(crate::tl!("Alignment:")); for (k, label) in TAB_KINDS { - if ui.radio(cur == k, label).clicked() { + if ui.radio(cur == k, crate::i18n::t(label)).clicked() { d.fields.insert("newalign".into(), k.into()); } } }); ui.horizontal(|ui| { - if ui.button("Set").clicked() + if ui.button(crate::tl!("Set")).clicked() && let Some(p) = Some(num(d, "newpos", f64::NAN)).filter(|v| v.is_finite() && *v >= 0.0 && *v <= 56.0) { stops.retain(|(q, _)| (q - p).abs() > 0.005); @@ -3428,7 +3507,7 @@ fn tabs_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { stops.sort_by(|a, b| a.0.total_cmp(&b.0)); d.fields.insert("newpos".into(), String::new()); } - if ui.button("Clear All").clicked() { + if ui.button(crate::tl!("Clear All")).clicked() { stops.clear(); } }); @@ -3454,8 +3533,8 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { // "Find what:" has the keyboard when the dialog opens, so Ctrl+F and typing finds (#951). let mut what = d.get("text", ""); ui.horizontal(|ui| { - ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Find what:")); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(180.0)); + let l = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(crate::tl!("Find what:"))); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(180.0)).labelled_by(l.id); // Enter finds the next match and leaves the keyboard here, so the search can be changed. if !d.fields.contains_key("focused") || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { r.request_focus(); @@ -3475,11 +3554,11 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.add_space(6.0); ui.horizontal(|ui| { let has = !text.is_empty(); - if ui.add_enabled(has, egui::Button::new("Find Next")).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { + if ui.add_enabled(has, egui::Button::new(crate::tl!("Find Next"))).clicked() || (has && ui.input(|i| i.key_pressed(egui::Key::Enter))) { find_next = true; } if replace_mode { - if ui.add_enabled(has, egui::Button::new("Replace")).clicked() { + if ui.add_enabled(has, egui::Button::new(crate::tl!("Replace"))).clicked() { let cur: Option = serde_json::from_str(&d.get("cur", "")).ok(); if let Some(at) = cur { let mut p = q.clone(); @@ -3492,27 +3571,30 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } find_next = true; } - if ui.add_enabled(has, egui::Button::new("Replace All")).clicked() { + if ui.add_enabled(has, egui::Button::new(crate::tl!("Replace All"))).clicked() { let mut p = q.clone(); p["with"] = json!(d.get("with", "")); let msg = match app.session.execute("text.replace", &p) { - Ok(v) => format!("Replaced {} occurrence(s).", v.get("replaced").and_then(Value::as_u64).unwrap_or(0)), - Err(_) => format!("DeckCraft can't find “{text}”."), + Ok(v) => crate::i18n::tf( + "Replaced {n} occurrence(s).", + &[("n", &v.get("replaced").and_then(Value::as_u64).unwrap_or(0).to_string())], + ), + Err(_) => crate::i18n::tf("DeckCraft can't find “{text}”.", &[("text", &text)]), }; d.fields.insert("status".into(), msg); d.fields.remove("cur"); } - } else if ui.button("Replace…").clicked() { + } else if ui.button(crate::tl!("Replace…")).clicked() { d.fields.insert("replace".into(), "1".into()); } - if ui.button("Close").clicked() { + if ui.button(crate::tl!("Close")).clicked() { close = true; } }); if find_next { let list = app.session.execute("text.find", &q).ok().and_then(|v| v.as_array().cloned()).unwrap_or_default(); if list.is_empty() { - d.fields.insert("status".into(), format!("DeckCraft can't find “{text}”.")); + d.fields.insert("status".into(), crate::i18n::tf("DeckCraft can't find “{text}”.", &[("text", &text)])); d.fields.remove("cur"); } else { let n = d.get("next", "0").parse::().unwrap_or(0) % list.len(); @@ -3523,10 +3605,18 @@ fn find_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.insert("cur".into(), m.to_string()); d.fields.insert("next".into(), (n + 1).to_string()); - let where_ = if m.get("shape").is_some_and(Value::is_null) { " (notes)" } else { "" }; + let where_ = if m.get("shape").is_some_and(Value::is_null) { format!(" ({})", crate::tl!("notes")) } else { String::new() }; d.fields.insert( "status".into(), - format!("Match {} of {} on slide {}{where_}.", n + 1, list.len(), m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1), + crate::i18n::tf( + "Match {n} of {total} on slide {slide}{where}.", + &[ + ("n", &(n + 1).to_string()), + ("total", &list.len().to_string()), + ("slide", &(m.get("slide").and_then(Value::as_u64).unwrap_or(0) + 1).to_string()), + ("where", &where_), + ], + ), ); } } @@ -3560,16 +3650,18 @@ fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result "Small (640 px wide)", - "large" => "Large (1280 px wide)", - "extraLarge" => "Extra Large (1920 px wide)", - "custom" => "Custom width", - _ => "Medium (960 px wide)", + let size_label = |k: &str| { + crate::i18n::t(match k { + "small" => "Small (640 px wide)", + "large" => "Large (1280 px wide)", + "extraLarge" => "Extra Large (1920 px wide)", + "custom" => "Custom width", + _ => "Medium (960 px wide)", + }) }; ui.add_space(4.0); ui.horizontal(|ui| { - let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:")); + let field = ui.add_sized(vec2(120.0, 20.0), egui::Label::new(crate::tl!("Size:"))); let combo = egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| { for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) { if ui.selectable_label(size == k, size_label(k)).clicked() { @@ -3592,7 +3684,10 @@ fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result Result= 1.0 && from <= to && to <= n as f64) { - return Err(format!("Slides: choose a range from 1 to {n}")); + return Err(crate::i18n::tf("Slides: choose a range from 1 to {n}", &[("n", &n.to_string())])); } if from > 1.0 || to < n as f64 { p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::>()); diff --git a/crates/ui-egui/src/doc_window.rs b/crates/ui-egui/src/doc_window.rs index dfff3d4b0..eace525e0 100644 --- a/crates/ui-egui/src/doc_window.rs +++ b/crates/ui-egui/src/doc_window.rs @@ -94,12 +94,12 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.horizontal_centered(|ui| { ui.label(RichText::new(title.clone()).font(crate::theme::bold(13.0)).color(t.text)); ui.add_space(16.0); - ui.label(RichText::new(format!("Slide {} of {n}", slide + 1)).color(t.text_dim).font(crate::theme::font(13.0))); + ui.label(RichText::new(crate::tl!("Slide {} of {}", slide + 1, n)).color(t.text_dim).font(crate::theme::font(13.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.add_enabled(slide + 1 < n, egui::Button::new("Next")).clicked() { + if ui.add_enabled(slide + 1 < n, egui::Button::new(crate::tl!("Next"))).clicked() { let _ = app.run("view.newWindow", json!({"delta": 1})); } - if ui.add_enabled(slide > 0, egui::Button::new("Previous")).clicked() { + if ui.add_enabled(slide > 0, egui::Button::new(crate::tl!("Previous"))).clicked() { let _ = app.run("view.newWindow", json!({"delta": -1})); } }); diff --git a/crates/ui-egui/src/eyedropper.rs b/crates/ui-egui/src/eyedropper.rs index 09a91a12a..7cbb43558 100644 --- a/crates/ui-egui/src/eyedropper.rs +++ b/crates/ui-egui/src/eyedropper.rs @@ -106,15 +106,15 @@ pub fn tick(app: &mut SlideApp, ctx: &Context) { fn begin(app: &mut SlideApp, pick: Pick) { let Some(d) = app.session.active() else { return }; if d.selection.target != deckcraft_engine::Target::Slides { - app.set_status("The Eyedropper takes colours from a slide"); + app.set_status(crate::tl!("The Eyedropper takes colours from a slide")); return; } let slide = d.selection.slide; let image = deckcraft_render::render_slide(&d.doc, slide, &deckcraft_render::RenderOpts { scale: SCALE, ..Default::default() }); app.eyedropper = Some(State { pick, slide, image }); app.set_status(match pick { - Pick::Menu(_) => "Eyedropper: click the slide (Esc cancels)", - Pick::TransparentColor => "Set Transparent Color: click the colour in the picture (Esc cancels)", + Pick::Menu(_) => crate::tl!("Eyedropper: click the slide (Esc cancels)"), + Pick::TransparentColor => crate::tl!("Set Transparent Color: click the colour in the picture (Esc cancels)"), }); } diff --git a/crates/ui-egui/src/fillui.rs b/crates/ui-egui/src/fillui.rs index 2410dc0ac..b667d3f59 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::tl!("Preset gradients")); ui.horizontal_wrapped(|ui| { let base = g.stops.first().map(|s| s.color.clone()).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)); let mut light = base.clone(); @@ -168,10 +168,10 @@ 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 combo = egui::ComboBox::from_id_salt((id, "kind")).selected_text(kind).width(120.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::tl!("Type"))); + let combo = egui::ComboBox::from_id_salt((id, "kind")).selected_text(crate::i18n::t(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 { + if ui.selectable_label(kind == k, crate::i18n::t(k)).clicked() && kind != k { let focus = match &g.shape { GradientShape::Path { focus, .. } => *focus, _ => [0.5, 0.5, 0.5, 0.5], @@ -189,7 +189,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> let _ = combo.response.labelled_by(field.id); }); ui.horizontal_wrapped(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Direction")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::tl!("Direction"))); match &g.shape { GradientShape::Linear { angle, .. } => { for (l, a) in DIRECTIONS_LINEAR { @@ -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, crate::i18n::t(l.trim_start_matches("From "))).on_hover_text(crate::i18n::t(l)).clicked() { out = Some(Gradient { shape: GradientShape::Path { path: path.clone(), focus: f }, ..g.clone() }); } } @@ -211,7 +211,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> }); if let GradientShape::Linear { angle, .. } = &g.shape { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Angle")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::tl!("Angle"))); if let Some(a) = widgets::spinner(ui, &format!("{id}-angle"), "Angle", *angle, 15.0, "°", 70.0) { out = Some(Gradient { shape: GradientShape::Linear { angle: a.rem_euclid(360.0), scaled: false }, ..g.clone() }); } @@ -220,7 +220,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // Stop bar: click to add, drag a stop to move it. ui.add_space(4.0); - ui.label("Gradient stops"); + ui.label(crate::tl!("Gradient stops")); let (bar, bar_resp) = ui.allocate_exact_size(vec2(ui.available_width().min(260.0), 18.0), Sense::click()); bar_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, "Gradient bar")); paint_bar(ui, bar, g, sc); @@ -278,7 +278,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> // The selected stop. if let Some(s) = g.stops.get(sel) { ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Color")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::tl!("Color"))); let (sw, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); ui.painter().rect(sw, CornerRadius::same(3), c32(&s.color, sc), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let r = widgets::drop_button(ui, "", vec2(24.0, 20.0), true); @@ -290,7 +290,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); let can_remove = g.stops.len() > 2; - if ui.add_enabled(can_remove, egui::Button::new("Remove stop")).clicked() { + if ui.add_enabled(can_remove, egui::Button::new(crate::tl!("Remove stop"))).clicked() { let mut ng = g.clone(); ng.stops.remove(sel); sel = sel.saturating_sub(1); @@ -298,7 +298,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Position")); + ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::tl!("Position"))); if let Some(v) = widgets::spinner(ui, &format!("{id}-pos-{sel}"), "Position", (s.pos * 100.0).round(), 5.0, "%", 70.0) { let mut ng = g.clone(); ng.stops[sel].pos = (v / 100.0).clamp(0.0, 1.0); @@ -307,7 +307,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) -> } }); ui.horizontal(|ui| { - let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new("Transparency")); + let label = ui.add_sized(vec2(70.0, 20.0), egui::Label::new(crate::tl!("Transparency"))); let key = ui.scope_id().with((id, "tr", sel)); let mut tr: f32 = ui.data_mut(|d| d.get_temp(key).unwrap_or(((1.0 - alpha_of(&s.color)) * 100.0) as f32)); let r = ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).suffix("%")).labelled_by(label.id); diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs new file mode 100644 index 000000000..686c2ede8 --- /dev/null +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -0,0 +1,110 @@ +//! Compile-time embedded translation catalog loaded from UTF-8 `.tsv` files. +//! +//! File format (`.tsv`): +//! - One entry per line: `\t` +//! - Blank lines and lines starting with `#` (after trimming leading spaces) are ignored. +//! - Escape sequences inside keys and values: `\n` (newline), `\t` (tab), `\\` (backslash). +//! - Context-disambiguated keys use the convention `::` (see [`super::tc`]). +//! - Plural keys use `#one` and `#other` (or `#zero`, `#few`, `#many` +//! for languages that need finer categories; Vietnamese uses the invariant `#other` form, +//! falling back to `#one` or the base key if only one is supplied). + +use std::collections::HashMap; +use std::sync::OnceLock; + +/// Parsed translation catalog for a single target language. +#[derive(Debug, Default)] +pub struct Catalog { + entries: HashMap, +} + +impl Catalog { + /// Parse a UTF-8 `.tsv` catalog source string. + #[must_use] + pub fn parse(tsv: &str) -> Self { + let mut entries = HashMap::new(); + for raw_line in tsv.lines() { + let line = raw_line.trim_end_matches('\r'); + let trimmed_start = line.trim_start_matches(' '); + if trimmed_start.is_empty() || trimmed_start.starts_with('#') { + continue; + } + if let Some((raw_k, raw_v)) = line.split_once('\t') { + let k = unescape(raw_k); + let v = unescape(raw_v); + if !k.is_empty() && !v.is_empty() { + entries.insert(k, v); + } + } + } + Self { entries } + } + + /// Look up an exact key, returning the translated slice when present. + #[must_use] + pub fn get<'a>(&'a self, key: &str) -> Option<&'a str> { + self.entries.get(key).map(String::as_str) + } + + /// Look up a context-qualified key (`"::"`), falling back to ``. + #[must_use] + pub fn get_ctx<'a>(&'a self, ctx: &str, key: &str) -> Option<&'a str> { + let mut buf = String::with_capacity(ctx.len() + 2 + key.len()); + buf.push_str(ctx); + buf.push_str("::"); + buf.push_str(key); + self.get(&buf).or_else(|| self.get(key)) + } + + /// Number of entries in the catalog. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether the catalog has zero entries. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Iterate over `(key, value)` pairs in the catalog. + pub fn iter(&self) -> impl Iterator { + self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str())) + } +} + +fn unescape(s: &str) -> String { + if !s.contains('\\') { + return s.to_owned(); + } + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => out.push('\n'), + Some('t') => out.push('\t'), + Some('\\') => out.push('\\'), + Some(other) => { + out.push('\\'); + out.push(other); + } + None => out.push('\\'), + } + } else { + out.push(c); + } + } + out +} + +/// Raw UTF-8 `.tsv` source for the Vietnamese (`vi`) catalog. +pub const VI_TSV: &str = include_str!("vi.tsv"); + +/// Lazily parsed global Vietnamese catalog. +#[must_use] +pub fn vi_catalog() -> &'static Catalog { + static VI: OnceLock = OnceLock::new(); + VI.get_or_init(|| Catalog::parse(VI_TSV)) +} diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs new file mode 100644 index 000000000..031e1dda8 --- /dev/null +++ b/crates/ui-egui/src/i18n/mod.rs @@ -0,0 +1,434 @@ +//! Internationalization (i18n) and localization (L10n) layer for DeckCraft UI. +//! +//! Architecture: +//! - English (`en`) is the canonical source key language; zero allocation when running in English. +//! - Additional languages are loaded from UTF-8 `.tsv` catalogs embedded at compile time +//! (see [`catalog::VI_TSV`]), keeping the binary self-contained with zero runtime file I/O. +//! - Supports: +//! - [`t`]: direct key lookup (`t("File") -> "Tệp"`). +//! - [`tc`]: context-disambiguated lookup (`tc("menu", "View")` looks up `"menu::View"`, +//! falling back to `"View"`). +//! - [`tn`]: plural-aware lookup (`tn("Slide", "{n} slides", n)` looks up `"Slide#one"` / +//! `"Slide#other"` and substitutes `{n}`). +//! - [`tf`]: named placeholder formatting (`tf("Slide {cur} of {total}", &[("cur", "1"), ("total", "5")])`). +//! - [`fmt`]: positional `{}` placeholder formatting (`fmt("Saved {}", &[path])`). +//! - [`Lang::from_pref`]: resolves `"auto"` / `"system"` against OS locale (`DECKCRAFT_LOCALE`, +//! `LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`, macOS `AppleLanguages`, Windows registry) +//! with graceful fallback to English. + +pub mod catalog; + +use std::cell::Cell; +use std::sync::OnceLock; + +pub use catalog::{Catalog, vi_catalog}; + +/// Supported UI display languages. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum Lang { + /// English (United States) — canonical source language. + #[default] + EN, + /// Vietnamese (`Tiếng Việt`). + VI, +} + +/// Ordered list of all compiled-in languages for settings dropdowns and introspection. +pub const ALL_LANGS: &[Lang] = &[Lang::EN, Lang::VI]; + +impl Lang { + /// All supported languages in selector order. + pub const ALL: [Self; 2] = [Self::EN, Self::VI]; + + /// BCP-47 / ISO 639-1 language code (`"en"`, `"vi"`). + #[must_use] + pub const fn code(self) -> &'static str { + match self { + Self::EN => "en", + Self::VI => "vi", + } + } + + /// Native display name shown in the language selector (`"English"`, `"Tiếng Việt"`). + #[must_use] + pub const fn native_name(self) -> &'static str { + match self { + Self::EN => "English", + Self::VI => "Tiếng Việt", + } + } + + /// Alias for [`Self::native_name`] used in UI selectors. + #[must_use] + pub const fn display_name(self) -> &'static str { + self.native_name() + } + + /// English display name (`"English"`, `"Vietnamese"`). + #[must_use] + pub const fn english_name(self) -> &'static str { + match self { + Self::EN => "English", + Self::VI => "Vietnamese", + } + } + + /// Parse a user preference string (`"auto"`, `"en"`, `"vi"`, `"vi-VN"`, `"vi_VN.UTF-8"`, etc.). + /// + /// When `pref` is `""`, `"auto"`, `"system"`, or `"default"`, queries [`system_lang`]. + /// Unknown language tags fall back to [`Lang::EN`]. + #[must_use] + pub fn from_pref(pref: &str) -> Self { + let trimmed = pref.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("auto") + || trimmed.eq_ignore_ascii_case("system") + || trimmed.eq_ignore_ascii_case("default") + { + return system_lang(); + } + lang_from_tag(trimmed).unwrap_or(Self::EN) + } + + /// Normalize a preference string into one of `"auto"`, `"en"`, or `"vi"`. + #[must_use] + pub fn normalize_pref(pref: &str) -> &'static str { + let trimmed = pref.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("auto") + || trimmed.eq_ignore_ascii_case("system") + || trimmed.eq_ignore_ascii_case("default") + { + "auto" + } else { + match lang_from_tag(trimmed) { + Some(Self::VI) => "vi", + Some(Self::EN) | None => "en", + } + } + } +} + +/// Parse an arbitrary locale tag (`"vi"`, `"vi_VN.UTF-8"`, `"vi-VN"`, `"Tiếng Việt"`, `"en_US"`) +/// into a supported [`Lang`], or `None` if unrecognized. +#[must_use] +pub fn lang_from_tag(tag: &str) -> Option { + let clean = tag.trim().trim_matches('"').trim_matches('\''); + if clean.is_empty() { + return None; + } + let lower = clean.to_lowercase(); + if lower == "tiếng việt" || lower == "tieng viet" || lower == "vietnamese" || lower == "vie" { + return Some(Lang::VI); + } + if lower == "english" || lower == "eng" || lower == "c" || lower == "posix" { + return Some(Lang::EN); + } + let without_encoding = lower.split('.').next().unwrap_or(lower.as_str()); + let without_modifier = without_encoding + .split('@') + .next() + .unwrap_or(without_encoding); + let primary = without_modifier + .split(['_', '-']) + .next() + .unwrap_or(without_modifier); + match primary { + "vi" | "vie" => Some(Lang::VI), + "en" | "eng" => Some(Lang::EN), + _ => None, + } +} + +/// Detect the operating system's preferred UI language, cached after first call. +/// +/// Note: Under `cfg!(test)`, `system_lang()` always returns [`Lang::EN`] so that existing +/// English UI snapshot and accessibility tests are unaffected by the developer's host locale. +/// Locale detection helpers are tested directly via [`detect_system_lang_from_env`]. +#[must_use] +pub fn system_lang() -> Lang { + if cfg!(test) { + return Lang::EN; + } + static DETECTED: OnceLock = OnceLock::new(); + *DETECTED.get_or_init(detect_system_lang) +} + +fn detect_system_lang() -> Lang { + if let Some(lang) = detect_system_lang_from_env(|k| std::env::var(k).ok()) { + return lang; + } + #[cfg(target_os = "macos")] + if let Some(lang) = detect_macos_apple_languages() { + return lang; + } + #[cfg(target_os = "windows")] + if let Some(lang) = detect_windows_locale() { + return lang; + } + Lang::EN +} + +/// Inspect standard locale environment variables (`DECKCRAFT_LOCALE`, `DECKCRAFT_LANGUAGE`, +/// `LC_ALL`, `LC_MESSAGES`, `LANG`, `LANGUAGE`) using a caller-supplied lookup closure. +#[must_use] +pub fn detect_system_lang_from_env(mut get_env: F) -> Option +where + F: FnMut(&str) -> Option, +{ + for var in [ + "DECKCRAFT_LOCALE", + "DECKCRAFT_LANGUAGE", + "LC_ALL", + "LC_MESSAGES", + "LANG", + "LANGUAGE", + ] { + if let Some(val) = get_env(var) { + for part in val.split(':') { + let trimmed = part.trim(); + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("c") + || trimmed.eq_ignore_ascii_case("posix") + || trimmed.eq_ignore_ascii_case("c.utf-8") + { + continue; + } + if let Some(lang) = lang_from_tag(trimmed) { + return Some(lang); + } + } + } + } + None +} + +#[cfg(target_os = "macos")] +fn detect_macos_apple_languages() -> Option { + let output = std::process::Command::new("/usr/bin/defaults") + .args(["read", "-g", "AppleLanguages"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let cleaned = line.trim().trim_matches(&['(', ')', ',', '"', '\''][..]); + if cleaned.is_empty() { + continue; + } + if let Some(lang) = lang_from_tag(cleaned) { + return Some(lang); + } + } + None +} + +#[cfg(target_os = "windows")] +fn detect_windows_locale() -> Option { + let sys_root = std::env::var_os("SystemRoot") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\Windows")); + let reg_exe = sys_root.join("System32").join("reg.exe"); + let output = std::process::Command::new(reg_exe) + .args(["query", r"HKCU\Control Panel\International", "/v", "LocaleName"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + for token in text.split_whitespace().rev() { + if let Some(lang) = lang_from_tag(token) { + return Some(lang); + } + } + None +} + +thread_local! { + static CURRENT: Cell = const { Cell::new(Lang::EN) }; +} + +/// Set the active UI language for the current thread. +pub fn set_current(lang: Lang) { + CURRENT.with(|c| c.set(lang)); +} + +/// Get the active UI language for the current thread. +#[must_use] +pub fn current() -> Lang { + CURRENT.with(Cell::get) +} + +/// Translate an English UI key into `lang`, returning a borrowed `'static` slice when possible. +/// +/// Falls back to returning `key` unchanged if no translation entry exists. +#[must_use] +pub fn tr<'a>(lang: Lang, key: &'a str) -> &'a str { + match lang { + Lang::EN => key, + Lang::VI => vi_catalog().get(key).unwrap_or(key), + } +} + +/// Translate an English UI key using the current thread's active language. +#[must_use] +pub fn t(key: &str) -> &str { + tr(current(), key) +} + +/// Translate an English UI key with a disambiguating context (`ctx`), e.g. +/// `tc("menu", "View")` or `tc("noun", "Slide")`. +/// +/// Looks up `"::"` first, then falls back to ``, and finally returns `key`. +#[must_use] +pub fn tr_ctx<'a>(lang: Lang, ctx: &str, key: &'a str) -> &'a str { + match lang { + Lang::EN => key, + Lang::VI => vi_catalog().get_ctx(ctx, key).unwrap_or(key), + } +} + +/// Context-disambiguated translation using the current thread's active language. +#[must_use] +pub fn tc<'a>(ctx: &str, key: &'a str) -> &'a str { + tr_ctx(current(), ctx, key) +} + +/// Plural-aware translation with `{n}` (and `{}` / `{count}`) substitution. +/// +/// - In `Lang::EN`, selects `singular` when `n == 1` and `plural` otherwise. +/// - In `Lang::VI`, looks up `"#other"` (or `"#one"` when `n == 1`), +/// falling back to translating `plural` (or `singular` when `n == 1`). +#[must_use] +pub fn tr_plural(lang: Lang, singular: &str, plural: &str, n: usize) -> String { + let template = match lang { + Lang::EN => { + if n == 1 { + singular + } else { + plural + } + } + Lang::VI => { + let cat = vi_catalog(); + let specific_key = if n == 1 { + format!("{singular}#one") + } else { + format!("{singular}#other") + }; + cat.get(&specific_key) + .or_else(|| { + let other_key = format!("{singular}#other"); + cat.get(&other_key) + }) + .or_else(|| { + if n == 1 { + cat.get(singular) + } else { + cat.get(plural) + } + }) + .unwrap_or(if n == 1 { singular } else { plural }) + } + }; + let n_str = n.to_string(); + template + .replace("{n}", &n_str) + .replace("{count}", &n_str) + .replace("{}", &n_str) +} + +/// Plural-aware translation using the current thread's active language. +#[must_use] +pub fn tn(singular: &str, plural: &str, n: usize) -> String { + tr_plural(current(), singular, plural, n) +} + +/// Translate `key` in `lang` and substitute named placeholders (`{name}` -> value). +#[must_use] +pub fn tr_fmt(lang: Lang, key: &str, args: &[(&str, &str)]) -> String { + let base = tr(lang, key); + if args.is_empty() { + return base.to_owned(); + } + let mut out = base.to_owned(); + for (name, val) in args { + let mut token = String::with_capacity(name.len() + 2); + token.push('{'); + token.push_str(name); + token.push('}'); + out = out.replace(&token, val); + } + out +} + +/// Translate `key` using the current thread's active language and substitute named placeholders. +#[must_use] +pub fn tf(key: &str, args: &[(&str, &str)]) -> String { + tr_fmt(current(), key, args) +} + +/// Translate `key` using the current thread's active language and substitute positional `{}` +/// (or `{0}`, `{1}`, …) placeholders in order. +#[must_use] +pub fn fmt(key: &str, args: &[&str]) -> String { + fmt_lang(current(), key, args) +} + +/// Translate `key` in `lang` and substitute positional `{}` (or `{0}`, `{1}`, …) placeholders. +#[must_use] +pub fn fmt_lang(lang: Lang, key: &str, args: &[&str]) -> String { + let tpl = tr(lang, key); + if args.is_empty() { + return tpl.to_owned(); + } + let mut indexed = tpl.to_owned(); + let mut used_indexed = false; + for (idx, val) in args.iter().enumerate() { + let token = format!("{{{idx}}}"); + if indexed.contains(&token) { + indexed = indexed.replace(&token, val); + used_indexed = true; + } + } + if used_indexed && !indexed.contains("{}") { + return indexed; + } + let mut out = String::with_capacity(indexed.len() + 16); + let mut parts = indexed.split("{}"); + if let Some(first) = parts.next() { + out.push_str(first); + } + for (idx, part) in parts.enumerate() { + if let Some(val) = args.get(idx) { + out.push_str(val); + } else { + out.push_str("{}"); + } + out.push_str(part); + } + out +} + +/// Convenience macro for translating static or formatted UI labels. +/// +/// - `tl!("File")` -> `crate::i18n::t("File")` (`&str`) +/// - `tl!("Slide {} of {}", cur, total)` -> `crate::i18n::fmt("Slide {} of {}", &[...])` (`String`) +#[macro_export] +macro_rules! tl { + ($key:expr) => { + $crate::i18n::t($key) + }; + ($key:expr, $($name:ident = $val:expr),+ $(,)?) => { + $crate::i18n::tf($key, &[ $((stringify!($name), &format!("{}", $val))),+ ]) + }; + ($key:expr, $($arg:expr),+ $(,)?) => { + $crate::i18n::fmt($key, &[$(&format!("{}", $arg)),+]) + }; +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 000000000..922efafe3 --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,300 @@ +//! Unit and catalog integrity tests for DeckCraft i18n (`en` / `vi`). + +use std::collections::HashSet; + +use serde_json::json; + +use super::catalog::{Catalog, VI_TSV, vi_catalog}; +use super::*; + +fn extract_placeholders(s: &str) -> Vec { + let mut out = Vec::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '{' { + let mut token = String::from("{"); + for nxt in chars.by_ref() { + token.push(nxt); + if nxt == '}' { + break; + } + } + if token.ends_with('}') { + out.push(token); + } + } + } + out.sort(); + out +} + +#[test] +fn locale_tags_resolve_to_supported_languages() { + for tag in [ + "vi", + "VI", + "vi-VN", + "vi_VN", + "vi_VN.UTF-8", + "vi_VN.UTF-8@anything", + "vie", + "Tiếng Việt", + "tieng viet", + "Vietnamese", + ] { + assert_eq!(lang_from_tag(tag), Some(Lang::VI), "tag {tag:?} should map to VI"); + } + + for tag in ["en", "EN", "en-US", "en_GB.UTF-8", "eng", "English", "C", "POSIX"] { + assert_eq!(lang_from_tag(tag), Some(Lang::EN), "tag {tag:?} should map to EN"); + } + + for unsupported in ["", " ", "qaa-QM", "fr-FR", "ja_JP.UTF-8"] { + assert_eq!(lang_from_tag(unsupported), None, "tag {unsupported:?} should be None"); + } +} + +#[test] +fn preferences_parse_and_normalize_with_fallback() { + assert_eq!(Lang::from_pref("vi"), Lang::VI); + assert_eq!(Lang::from_pref("VI-VN"), Lang::VI); + assert_eq!(Lang::from_pref("Tiếng Việt"), Lang::VI); + assert_eq!(Lang::from_pref("en"), Lang::EN); + assert_eq!(Lang::from_pref("en-US"), Lang::EN); + // Under #[cfg(test)], system_lang() is deterministic (Lang::EN). + assert_eq!(Lang::from_pref("auto"), Lang::EN); + assert_eq!(Lang::from_pref("system"), Lang::EN); + assert_eq!(Lang::from_pref(""), Lang::EN); + assert_eq!(Lang::from_pref("unknown-tag"), Lang::EN); + + assert_eq!(Lang::normalize_pref(""), "auto"); + assert_eq!(Lang::normalize_pref("AUTO"), "auto"); + assert_eq!(Lang::normalize_pref("system"), "auto"); + assert_eq!(Lang::normalize_pref("vi"), "vi"); + assert_eq!(Lang::normalize_pref("vi_VN.UTF-8"), "vi"); + assert_eq!(Lang::normalize_pref("en-GB"), "en"); + assert_eq!(Lang::normalize_pref("unknown"), "en"); +} + +#[test] +fn env_locale_detection_honors_priority_and_skips_c_locale() { + let lang = detect_system_lang_from_env(|key| match key { + "LC_ALL" => Some("C.UTF-8".into()), + "LANG" => Some("vi_VN.UTF-8".into()), + _ => None, + }); + assert_eq!(lang, Some(Lang::VI)); + + let colon_list = detect_system_lang_from_env(|key| match key { + "LANGUAGE" => Some("qaa_QM:vi_VN:en_US".into()), + _ => None, + }); + assert_eq!(colon_list, Some(Lang::VI)); + + let override_var = detect_system_lang_from_env(|key| match key { + "DECKCRAFT_LOCALE" => Some("vi".into()), + "LC_ALL" => Some("en_US.UTF-8".into()), + _ => None, + }); + assert_eq!(override_var, Some(Lang::VI)); +} + +#[test] +fn thread_local_language_is_isolated_per_thread() { + set_current(Lang::VI); + assert_eq!(current(), Lang::VI); + assert_eq!(t("File"), "Tệp"); + assert_eq!(t("Slide Show"), "Trình chiếu"); + + std::thread::spawn(|| { + assert_eq!(current(), Lang::EN); + assert_eq!(t("File"), "File"); + }) + .join() + .unwrap(); + + set_current(Lang::EN); + assert_eq!(current(), Lang::EN); + assert_eq!(t("File"), "File"); +} + +#[test] +fn context_plural_and_placeholder_helpers_work() { + let custom_tsv = "\ +# sample catalog +View\tXem +noun::View\tChế độ xem +Slide#one\t{n} trang chiếu +Slide#other\t{n} trang chiếu +Slide {cur} of {total}\tTrang chiếu {cur} / {total} +Exported {}\tĐã xuất {} +"; + let cat = Catalog::parse(custom_tsv); + assert_eq!(cat.get("View"), Some("Xem")); + assert_eq!(cat.get_ctx("noun", "View"), Some("Chế độ xem")); + assert_eq!(cat.get_ctx("unknown_ctx", "View"), Some("Xem")); + + set_current(Lang::VI); + assert_eq!( + tf("Slide {i} of {n}", &[("i", "3"), ("n", "12")]), + "Trang chiếu 3 / 12" + ); + assert_eq!( + tl!("Slide {i} of {n}", i = 3, n = 12), + "Trang chiếu 3 / 12" + ); + assert_eq!( + tl!("Exported {}", "deck.pdf"), + "Đã xuất deck.pdf" + ); + assert_eq!(tn("{n} slide", "{n} slides", 1), "1 trang chiếu"); + assert_eq!(tn("{n} slide", "{n} slides", 5), "5 trang chiếu"); + + set_current(Lang::EN); + assert_eq!(tn("{n} slide", "{n} slides", 1), "1 slide"); + assert_eq!(tn("{n} slide", "{n} slides", 5), "5 slides"); +} + +#[test] +fn vietnamese_catalog_is_well_formed_and_preserves_placeholders() { + let cat = vi_catalog(); + assert!( + cat.len() >= 2000, + "expected comprehensive Vietnamese catalog (>= 2000 entries), got {}", + cat.len() + ); + + let mut seen_keys = HashSet::new(); + for (line_no, raw_line) in VI_TSV.lines().enumerate() { + let line = raw_line.trim_end_matches('\r'); + let trimmed = line.trim_start_matches(' '); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + let Some((raw_k, raw_v)) = line.split_once('\t') else { + panic!("line {} in vi.tsv is missing a tab separator: {:?}", line_no + 1, line); + }; + assert!( + !raw_k.is_empty() && !raw_v.is_empty(), + "line {} in vi.tsv has empty key or value", + line_no + 1 + ); + assert!( + seen_keys.insert(raw_k), + "duplicate key on line {} in vi.tsv: {:?}", + line_no + 1, + raw_k + ); + } + + for (k, v) in cat.iter() { + assert_eq!( + extract_placeholders(k), + extract_placeholders(v), + "placeholder mismatch for key {k:?} -> {v:?}" + ); + if !k.contains("...") { + assert!( + !v.contains("..."), + "use single-character ellipsis '…' instead of '...' in translation for {k:?}: {v:?}" + ); + } + if k.ends_with('…') { + assert!( + v.ends_with('…'), + "translation for {k:?} must preserve trailing ellipsis '…': {v:?}" + ); + } + } +} + +#[test] +fn vietnamese_covers_deckcraft_ribbon_backstage_views_and_dialogs() { + for (en, expected_vi) in [ + ("File", "Tệp"), + ("Home", "Trang đầu"), + ("Insert", "Chèn"), + ("Design", "Thiết kế"), + ("Transitions", "Chuyển tiếp"), + ("Animations", "Hoạt ảnh"), + ("Slide Show", "Trình chiếu"), + ("Review", "Rà soát"), + ("View", "Xem"), + ("Undo", "Hoàn tác"), + ("Redo", "Làm lại"), + ("Cut", "Cắt"), + ("Copy", "Sao chép"), + ("Paste", "Dán"), + ("New Slide", "Trang chiếu mới"), + ("Layout", "Bố cục"), + ("Reset", "Đặt lại"), + ("Section", "Phân mục"), + ("Selection Pane", "Ngăn lựa chọn"), + ("Animation Pane", "Ngăn hiệu ứng động"), + ("Slide Sorter", "Sắp xếp trang chiếu"), + ("Notes Page", "Trang ghi chú"), + ("Reading View", "Chế độ đọc"), + ("Presenter View", "Chế độ diễn giả"), + ("Preferences", "Tùy chọn"), + ("Language", "Ngôn ngữ"), + ("System default", "Mặc định hệ thống"), + ] { + assert_eq!(tr(Lang::VI, en), expected_vi, "unexpected translation for {en:?}"); + } +} + +#[test] +fn language_command_updates_ui_state_and_persists() { + let mut app = crate::SlideApp::new( + deckcraft_engine::Session::new(), + crate::Services::default(), + ); + assert_eq!(app.ui.language, "auto"); + + let res = app + .run("ui.language", json!({ "language": "vi-VN" })) + .expect("ui.language command should succeed"); + assert_eq!(app.ui.language, "vi"); + assert_eq!(res["language"], "vi"); + assert_eq!(res["resolved"], "vi"); + assert_eq!(current(), Lang::VI); + + let serialized = serde_json::to_string(&app.ui).expect("serialize UiState"); + let restored: crate::UiState = serde_json::from_str(&serialized).expect("deserialize UiState"); + assert_eq!(restored.language, "vi"); + assert_eq!(Lang::from_pref(&restored.language), Lang::VI); + + // Legacy settings without `language` field default to `"auto"`. + let legacy: crate::UiState = serde_json::from_str("{}").expect("deserialize legacy UiState"); + assert_eq!(legacy.language, "auto"); + + // Reset back to English for subsequent tests. + let _ = app.run("ui.language", json!({ "language": "en" })); + assert_eq!(current(), Lang::EN); +} + +#[test] +fn every_command_and_menu_path_is_translated() { + let cat = vi_catalog(); + let mut missing = Vec::new(); + for spec in deckcraft_engine::cmd::command_specs() { + if !spec.label.is_empty() && cat.get(spec.label).is_none() { + missing.push(format!("engine command {} label {:?}", spec.id, spec.label)); + } + for seg in spec.menu { + if !seg.is_empty() && cat.get(seg).is_none() { + missing.push(format!("engine command {} menu {:?}", spec.id, seg)); + } + } + } + for (id, label, _, _) in crate::UI_COMMANDS { + if !label.is_empty() && cat.get(label).is_none() { + missing.push(format!("ui command {id} label {label:?}")); + } + } + assert!( + missing.is_empty(), + "untranslated command labels or menu segments: {missing:#?}" + ); +} + diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv new file mode 100644 index 000000000..4ca62586f --- /dev/null +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -0,0 +1,2265 @@ +# DeckCraft Vietnamese (Tiếng Việt) localization catalog +# Format: \t + + Height Chiều cao + Width Chiều rộng + Fixed date (empty = update automatically): Ngày cố định (để trống = tự động cập nhật): + Footer text: Nội dung chân trang: + (not checked) (không kiểm tra) + ({sha}, {date}) ({sha}, {date}) + ({x}) ({x}) + pt pt + — Edited — Đã sửa + — {n} — {n} +% of all commits % tổng số commit +(missing) (thiếu) +(no spelling suggestions) (không có gợi ý chính tả) +(none) (không có) ++ Add + Thêm ++ New Comment + Bình luận mới ++ New Slide + Trang chiếu mới ++ Row + Hàng ++ Series + Chuỗi ++Bin +Bin ++LOC +LOC +1 Column 1 cột +1 Slide 1 trang chiếu +1 picture 1 ảnh +1 picture with title 1 ảnh có tiêu đề +1.5 lines 1,5 dòng +100% Stacked Bar Thanh chồng 100% +100% Stacked Column Cột chồng 100% +16:10 16:10 +16:9 16:9 +2 Columns 2 cột +2 pictures 2 ảnh +2 pictures with title 2 ảnh có tiêu đề +3 Columns 3 cột +3-D 3-D +3-D Format Định dạng 3-D +3-D Rotation Xoay 3-D +4 Point Star Ngôi sao 4 cánh +4 pictures 4 ảnh +4 pictures with title 4 ảnh có tiêu đề +4:3 4:3 +5 Point Star Ngôi sao 5 cánh +6 Point Star Ngôi sao 6 cánh +8 Point Star Ngôi sao 8 cánh +@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {} @{}: {} PR, {} commit, +{} / −{} dòng (Δ {}), +{} / −{} tệp nhị phân, {} – {} +A fixed copy that keeps its layout on any device Bản sao cố định giữ nguyên bố cục trên mọi thiết bị +A photo album holds at most 48 pictures or text boxes Album ảnh chứa tối đa 48 ảnh hoặc hộp văn bản +A public talk the audience can check on the way home Bài diễn thuyết công khai để khán giả xem lại trên đường về +A report meant to be read aloud in half an hour Báo cáo để trình bày trong nửa giờ +A short class that ends with the learner explaining the idea Bài giảng ngắn kết thúc bằng việc người học tự giải thích lại ý tưởng +A ten-minute conversation about one promise, not a tour of features Cuộc trò chuyện mười phút về một cam kết cốt lõi, không phải màn liệt kê tính năng +A weekly check-in: what changed, what's stuck, and who does the next thing Cập nhật hằng tuần: những gì đã thay đổi, những gì đang vướng và ai làm việc tiếp theo +A4 A4 +ALL pictures black and white TẤT CẢ ảnh đen trắng +AaBbCcDd AaBbCcDd +About Giới thiệu +About DeckCraft Giới thiệu về DeckCraft +Above Phía trên +Accent 1 Điểm nhấn 1 +Accent 2 Điểm nhấn 2 +Accent 3 Điểm nhấn 3 +Accent 4 Điểm nhấn 4 +Accent 5 Điểm nhấn 5 +Accent 6 Điểm nhấn 6 +Accent Process Quy trình điểm nhấn +Accent color 1 Dark Màu nhấn 1 Đậm +Accent color 1 Light Màu nhấn 1 Nhạt +Accent color 2 Dark Màu nhấn 2 Đậm +Accent color 2 Light Màu nhấn 2 Nhạt +Accent color 3 Dark Màu nhấn 3 Đậm +Accent color 3 Light Màu nhấn 3 Nhạt +Accent color 4 Dark Màu nhấn 4 Đậm +Accent color 4 Light Màu nhấn 4 Nhạt +Accent color 5 Dark Màu nhấn 5 Đậm +Accent color 5 Light Màu nhấn 5 Nhạt +Accent color 6 Dark Màu nhấn 6 Đậm +Accent color 6 Light Màu nhấn 6 Nhạt +Accept Chấp nhận +Accept\nAll Chấp nhận\ntất cả +Accept All Chấp nhận tất cả +Accept All ({count}) Chấp nhận tất cả ({count}) +Accept Revision Chấp nhận thay đổi +Accessibility Khả năng tiếp cận +Accessibility Checker Trình kiểm tra trợ năng +Account Tài khoản +Across Ngang qua +Action Thao tác +Action Button: {k} Nút thao tác: {k} +Action Buttons Nút thao tác +Action Settings Thiết lập thao tác +Action Settings… Thiết lập thao tác… +Action on click Thao tác khi nhấp chuột +Action on mouse over Thao tác khi di chuột qua +Add Thêm +Add\nAnimation Thêm\nhiệu ứng động +Add Animation Thêm hiệu ứng động +Add Assistant Thêm trợ lý +Add Guide Thêm đường căn +Add Horizontal Guide Thêm đường căn ngang +Add Pictures… Thêm ảnh… +Add Point Thêm điểm +Add Section Thêm phân mục +Add Shape Thêm hình +Add Shape Above Thêm hình phía trên +Add Shape After Thêm hình phía sau +Add Shape Before Thêm hình phía trước +Add Shape Below Thêm hình phía dưới +Add Vertical Guide Thêm đường căn dọc +Add a category Thêm danh mục +Add a picture or a text box first Hãy thêm một ảnh hoặc hộp văn bản trước +Add a tag Thêm thẻ +Add a title Thêm tiêu đề +Add a title with a picture or some text to get layout ideas. Thêm tiêu đề kèm ảnh hoặc văn bản để nhận gợi ý bố cục. +Add an author Thêm tác giả +Add comments Thêm bình luận +Add to Dictionary Thêm vào từ điển +Add to Quick Access Toolbar Thêm vào thanh công cụ truy cập nhanh +Address: Địa chỉ: +Adjust Điều chỉnh +Adjust Shape Điều chỉnh hình +Advanced Animation Hiệu ứng động nâng cao +After Previous Sau hiệu ứng trước +After text Sau văn bản +After: Sau: +Airplane Máy bay giấy +Album Album +Album theme Chủ đề album +Align Căn chỉnh +Align Bottom Căn dưới +Align Cell Text Căn văn bản ô +Align Center Căn giữa +Align Left Căn trái +Align Middle Căn giữa dọc +Align Right Căn phải +Align Selected Objects Căn các đối tượng đã chọn +Align Text Căn chữ +Align Top Căn trên +Align or Distribute Căn chỉnh hoặc phân bố +Align text to the bottom of the cell Căn văn bản xuống đáy ô +Align text to the center of the cell Căn văn bản vào giữa ô +Align text to the top of the cell Căn văn bản lên đỉnh ô +Align to Slide Căn theo trang chiếu +Alignment Căn chỉnh +Alignment: Căn chỉnh: +All Tất cả +All Borders Tất cả viền +All caps Viết hoa toàn bộ +All slides Tất cả trang chiếu +All slides on Đang bật tất cả trang chiếu +Alt Alt +Alt\nText Văn bản\nthay thế +Alt Text Văn bản thay thế +An open-source, clean-room project. Not affiliated with Microsoft. Dự án mã nguồn mở phát triển độc lập (clean-room). Không liên kết với Microsoft. +Angle Góc +Animated GIF (.gif) Ảnh động GIF (.gif) +Animation Hiệu ứng động +Animation\nPainter Sao chép\nhiệu ứng động +Animation\nPane Ngăn\nhiệu ứng động +Animation Gallery Thư viện hiệu ứng động +Animation Painter Sao chép hiệu ứng động +Animation Painter (double-click to keep it on) Sao chép hiệu ứng động (nhấp đúp để giữ bật) +Animation Pane Ngăn hiệu ứng động +Animation Styles Kiểu hiệu ứng động +Animations Hiệu ứng động +Animations on Slide Hiệu ứng động trên trang chiếu +Appear Xuất hiện +Appearance Diện mạo +Applies to this presentation's theme colours. Áp dụng cho bảng màu chủ đề của bài thuyết trình này. +Apply Áp dụng +Apply\nTo All Áp dụng\ncho tất cả +Apply Animation Áp dụng hiệu ứng động +Apply Design Idea Áp dụng ý tưởng thiết kế +Apply To All Áp dụng cho tất cả +Apply only to this picture Chỉ áp dụng cho ảnh này +Apply to All Áp dụng cho tất cả +Arch Down Vòm xuống +Arch Down Pour Vòm đổ xuống +Arch Up Vòm lên +Arch Up Pour Vòm đổ lên +Arcs Đường cung +Area Vùng +Arrange Sắp xếp +Arrow Mũi tên +Arrow at end Mũi tên ở cuối +Arrow at start Mũi tên ở đầu +Arrows Mũi tên +Arrows at both ends Mũi tên ở cả hai đầu +Art deco Art Deco +ArtCraft ArtCraft +Artistic Nghệ thuật +Artistic\nEffects Hiệu ứng\nnghệ thuật +Artistic Effects Hiệu ứng nghệ thuật +As One Object Như một đối tượng +As: Dạng: +At Tại +Audio Âm thanh +Audio from File… Âm thanh từ tệp… +Author Tác giả +AutoCorrect Tự động sửa +AutoCorrect Options… Tùy chọn Tự động sửa… +AutoCorrect as you type Tự động sửa khi nhập +Autofit Tự động vừa khít +Automatic Tự động +Available formats: Định dạng hiện có: +B B +Back Quay lại +Back (Esc) Quay lại (Esc) +Background Nền +Background\nStyles Kiểu\nnền +Background Styles Kiểu nền +Backspace Xóa lùi +Backward Lùi về sau +Balanced Cân bằng +Banded Columns Cột xen kẽ +Banded Rows Hàng xen kẽ +Baseline Đường cơ sở +Basic Bending Process Quy trình uốn cong cơ bản +Basic Block List Danh sách khối cơ bản +Basic Chevron Process Quy trình mũi tên chữ V cơ bản +Basic Cycle Chu trình cơ bản +Basic List Danh sách cơ bản +Basic Matrix Ma trận cơ bản +Basic Pie Biểu đồ tròn cơ bản +Basic Process Quy trình cơ bản +Basic Pyramid Kim tự tháp cơ bản +Basic Radial Sơ đồ tỏa tâm cơ bản +Basic Shapes Hình cơ bản +Basic Target Mục tiêu cơ bản +Basic Timeline Dòng thời gian cơ bản +Basic Venn Sơ đồ Venn cơ bản +Bean Hạt đậu +Before text Trước văn bản +Before you share it, check the presentation for things people with disabilities might find hard to read. Trước khi chia sẻ, hãy kiểm tra bài thuyết trình để phát hiện nội dung có thể gây khó đọc cho người khuyết tật. +Begin arrow Mũi tên đầu +Begin size Kích cỡ đầu +Beginning Đầu +Below Phía dưới +Bevel Vát +Bevel Perspective Phối cảnh vát cạnh +Bevel Rectangle Hình chữ nhật vát cạnh +Beveled Matte, White Khung mờ vát cạnh, trắng +Beveled Oval, Black Hình bầu dục vát cạnh, đen +Binary assets added Tài nguyên nhị phân đã thêm +Binary assets removed Tài nguyên nhị phân đã xóa +Black Đen +Black Pen Bút đen +Black and White: 25% Đen trắng: 25% +Black and White: 50% Đen trắng: 50% +Black and White: 75% Đen trắng: 75% +Black on Đang bật màn đen +Blank Presentation Bài thuyết trình trống +Blinds Rèm sáo +Block Arrows Mũi tên khối +Block Cycle Chu trình khối +Blue Xanh dương +Blue Pen Bút xanh dương +Blur Làm mờ +Body Nội dung +Body font Phông chữ nội dung +Body sample Mẫu chữ nội dung +Body: Nội dung: +Bold In đậm +Bold Flash Nháy đậm +Borders Đường viền +Both Cả hai +Bottom Dưới +Bottom Border Viền dưới +Bottom Left Dưới trái +Bottom Right Dưới phải +Bottom bevel Vát cạnh dưới +Bottom left Dưới trái +Bottom right Dưới phải +Bounce Nảy +Bounce Left Nảy sang trái +Bounce Right Nảy sang phải +Box Khung +Box and Whisker Biểu đồ hộp và râu +Brick Scene Khung cảnh tường gạch +Bright room Phòng sáng +Brightness Độ sáng +Bring Đưa lên +Bring\nForward Đưa lên\ntrước +Bring Forward Chuyển lên trước +Bring to Front Đưa lên trên cùng +Browse Duyệt +Browse for Themes needs the desktop app for now Duyệt tìm chủ đề hiện cần ứng dụng máy tính +Browse for Themes… Duyệt tìm chủ đề… +Browsed at a kiosk (full screen) Duyệt tại quầy thông tin (toàn màn hình) +Browsed by an individual (window) Cá nhân tự xem (trong cửa sổ) +Bubble Bong bóng +Build Xây dựng +Bulleted Dấu đầu dòng +Bullets Dấu đầu dòng +Bullets and Numbering Dấu đầu dòng và đánh số +Bullets and Numbering… Dấu đầu dòng và đánh số… +Button Nút +Button Pour Nút tròn đổ chữ +By Theo +By Paragraph Theo đoạn văn +CMYK CMYK +CSV CSV +Callouts Hình chú thích +Cameo Khung camera +Camera Máy ảnh +Camera capture is not available yet Tính năng thu hình từ camera chưa khả dụng +Can Down Hình trụ hướng xuống +Can Up Hình trụ hướng lên +Cancel Hủy +Cap type Kiểu đầu nét +Capitalize Each Word Chữ hoa đầu mỗi từ +Capitalize first letter of sentences Viết hoa chữ cái đầu câu +Capitalize names of days Viết hoa tên ngày trong tuần +Caps Đầu nét +Captions under pictures Chú thích dưới ảnh +Cartoon Hoạt hình +Cascade Down Thác đổ xuống +Cascade Up Thác đổ lên +Categories Hạng mục +Cell Margins Lề ô +Cell Size Kích cỡ ô +Cells Ô +Center Căn giữa +Center Shadow Rectangle Hình chữ nhật bóng đổ giữa +Center Tab Tab giữa +Centered Căn giữa +Change Đổi +Change\nChart Type Đổi\nkiểu biểu đồ +Change Case Đổi chữ hoa/thường +Change Chart Type Đổi kiểu biểu đồ +Change Colors Đổi màu +Change File Type Đổi loại tệp +Change Layout Đổi bố cục +Change Picture Đổi ảnh +Change Picture… Đổi ảnh… +Change Shape Đổi hình +Change Spelling Sửa chính tả +Change to: Đổi thành: +Character Spacing Khoảng cách ký tự +Character spacing must be a number Khoảng cách ký tự phải là một số +Characters Ký tự +Chart Biểu đồ +Chart Data Dữ liệu biểu đồ +Chart Design Thiết kế biểu đồ +Chart Elements Thành phần biểu đồ +Chart Layouts Bố cục biểu đồ +Chart Type Loại biểu đồ +Charts Biểu đồ +Chart… Biểu đồ… +Check Kiểm tra +Check\nAccessibility Kiểm tra\nkhả năng tiếp cận +Check Accessibility Kiểm tra khả năng tiếp cận +Check for Issues Kiểm tra vấn đề +Checkerboard Bàn cờ +Chevron Mũi tên chữ V +Chilly Lạnh dịu +Chime Chuông ngân +Choose a SmartArt Graphic Chọn đồ họa SmartArt +Choose a body font. Hãy chọn phông chữ nội dung. +Choose a font Chọn phông chữ +Choose a heading font. Hãy chọn phông chữ tiêu đề. +Choose where to save Chọn nơi lưu +Choosing a presentation to reuse needs the desktop app Chọn bài thuyết trình để tái sử dụng hiện cần ứng dụng máy tính +Choosing a sound file needs the desktop app Chọn tệp âm thanh hiện cần ứng dụng máy tính +Circle Hình tròn +Circle Pour Vòng tròn đổ chữ +Clear Xóa +Clear All Xóa tất cả +Clear All Formatting Xóa tất cả định dạng +Clear the tab at {pos}" Xóa điểm dừng tab tại {pos}" +Click Bấm +Click a symbol to insert it at the insertion point. Nhấp vào một ký hiệu để chèn tại vị trí con trỏ. +Click the slide to take the colour under the pointer Nhấp vào trang chiếu để lấy màu dưới con trỏ +Click the slide to zoom in. Esc cancels. Nhấp vào trang chiếu để phóng to. Nhấn Esc để hủy. +Click to add first slide Nhấp để thêm trang chiếu đầu tiên +Click to add notes Nhấp để thêm ghi chú +Click to add text Bấm để thêm chữ +Click to change this one; Shift-click to change all Nhấp để đổi từ này; giữ Shift và nhấp để đổi tất cả +Clipboard Bảng tạm +Clock Kim đồng hồ +Clockwise Theo chiều kim đồng hồ +Close Đóng +Close\nMaster View Đóng chế độ\nxem bản cái +Close Master View Đóng chế độ xem bản cái +Clustered Bar Thanh nhóm +Clustered Column Cột nhóm +Collapse Thu gọn +Collapse the Ribbon Thu gọn thanh công cụ +Color Màu +Color At Màu tại điểm +Color Pulse Nhịp màu +Color: Màu: +Colorful Nhiều màu +Colorful Range - Accent Colors 1 to 2 Dải đa sắc - Màu nhấn 1 đến 2 +Colorful Range - Accent Colors 2 to 3 Dải đa sắc - Màu nhấn 2 đến 3 +Colorful Range - Accent Colors 3 to 4 Dải đa sắc - Màu nhấn 3 đến 4 +Colorful Range - Accent Colors 4 to 5 Dải đa sắc - Màu nhấn 4 đến 5 +Colorful Range - Accent Colors 5 to 6 Dải đa sắc - Màu nhấn 5 đến 6 +Colors Màu +Columns Cột +Comb Răng lược +Combine Kết hợp +Combo Combo +Command Palette Bảng lệnh +Comment Nhận xét +Comments Nhận xét +Commits Commit +Communication Liên lạc +Community Links Liên kết cộng đồng +Company Công ty +Compare So sánh +Compare needs the desktop app for now Tính năng So sánh hiện cần ứng dụng máy tính +Compare this presentation with a revised copy from Review › Compare. So sánh bài thuyết trình này với bản chỉnh sửa từ Xem lại › So sánh. +Compared with {name} Đã so sánh với {name} +Compare… So sánh… +Complementary Color Màu bổ túc +Compose Text Soạn văn bản +Compound Frame, Black Khung kép, đen +Compound type Kiểu nét ghép +Compress Nén +Compress Pictures Nén ảnh +Compressed {n} picture(s): {before} KB → {after} KB Đã nén {n} ảnh: {before} KB → {after} KB +Compression options Tùy chọn nén +Connect Shapes Nối hình +Connection Sites Điểm nối +Content Nội dung +Continuous Block Process Quy trình khối liên tục +Continuous Cycle Chu trình liên tục +Contour Đường bao +Contract Thu nhỏ +Contrast Độ tương phản +Contrasting Tương phản +Contributors Người đóng góp +Convert Chuyển đổi +Convert to Shapes Chuyển thành hình +Convert to SmartArt Chuyển thành SmartArt +Convert to Text Chuyển thành văn bản +Convex Lồi +Conveyor Băng chuyền +Cool slant Vát nghiêng dịu +Copied the path Đã sao chép đường dẫn +Copy Sao chép +Copy path Sao chép đường dẫn +Coral Reef Rạn san hô +Corner Point Điểm góc +Correct TWo INitial CApitals Sửa lỗi viết hoa hai chữ cái đầu +Correct accidental use of cAPS LOCK key Sửa lỗi vô tình bật phím cAPS LOCK +Corrections Hiệu chỉnh +Couldn't save AutoRecover information: {} Không thể lưu thông tin Tự động khôi phục: {} +Counterclockwise Ngược chiều kim đồng hồ +Cover Che phủ +Create Tạo +Create Graphic Tạo đồ họa +Create Handouts Tạo tài liệu phát tay +Create New Theme Colors Tạo bảng màu chủ đề mới +Create New Theme Fonts Tạo bộ phông chữ chủ đề mới +Create PDF Tạo PDF +Create PDF Document Tạo tài liệu PDF +Create a PDF Document Tạo tài liệu PDF +Crescent Moon Trăng lưỡi liềm +Crop Cắt xén +Crop 10% each side Cắt xén 10% mỗi cạnh +Crop 20% each side Cắt xén 20% mỗi cạnh +Crop to Shape Cắt xén theo hình +Cross Dấu chéo +Crush Vò giấy +Cube Khối lập phương +Currency Tiền tệ +Current Slide Trang chiếu hiện tại +Curtains Rèm sân khấu +Curve Down Uốn cong xuống +Curve Up Uốn cong lên +Curved Square Hình vuông bo cong +Custom Tùy chỉnh +Custom\nShow Trình chiếu\ntùy chỉnh +Custom Path Đường dẫn tùy chỉnh +Custom Range Phạm vi tùy chỉnh +Custom Show Trình chiếu tùy chỉnh +Custom Show 1 Trình chiếu tùy chỉnh 1 +Custom Shows Trình chiếu tùy chỉnh +Custom Show… Trình chiếu tùy chỉnh… +Custom Slide Show Trình chiếu tùy chỉnh +Custom character: Ký tự tùy chỉnh: +Customize Tùy chỉnh +Customize Colors… Tùy chỉnh màu… +Customize Fonts… Tùy chỉnh phông chữ… +Customize Quick Access Toolbar Tùy chỉnh thanh công cụ truy cập nhanh +Cut Cắt +Cutout Cắt ra +Cycle Chu trình +Cycle Matrix Ma trận chu trình +Dark Tối +Dark\nMode Chế độ\ntối +Dark Blue Xanh dương đậm +Dark Mode Chế độ tối +Dark Red Đỏ đậm +Dark Style 1 Kiểu tối 1 +Dark Style 1 - Accent 1 Kiểu tối 1 - Điểm nhấn 1 +Dark Style 1 - Accent 2 Kiểu tối 1 - Điểm nhấn 2 +Dark Style 1 - Accent 3 Kiểu tối 1 - Điểm nhấn 3 +Dark Style 1 - Accent 4 Kiểu tối 1 - Điểm nhấn 4 +Dark Style 1 - Accent 5 Kiểu tối 1 - Điểm nhấn 5 +Dark Style 1 - Accent 6 Kiểu tối 1 - Điểm nhấn 6 +Dark Style 2 Kiểu tối 2 +Dark Style 2 - Accent 1 Kiểu tối 2 - Điểm nhấn 1 +Dark Style 2 - Accent 2 Kiểu tối 2 - Điểm nhấn 2 +Dark Style 2 - Accent 3 Kiểu tối 2 - Điểm nhấn 3 +Dark Style 2 - Accent 4 Kiểu tối 2 - Điểm nhấn 4 +Dark Style 2 - Accent 5 Kiểu tối 2 - Điểm nhấn 5 +Dark Style 2 - Accent 6 Kiểu tối 2 - Điểm nhấn 6 +Dark Style 3 Kiểu tối 3 +Dark Style 3 - Accent 1 Kiểu tối 3 - Điểm nhấn 1 +Dark Style 3 - Accent 2 Kiểu tối 3 - Điểm nhấn 2 +Dark Style 3 - Accent 3 Kiểu tối 3 - Điểm nhấn 3 +Dark Style 3 - Accent 4 Kiểu tối 3 - Điểm nhấn 4 +Dark Style 3 - Accent 5 Kiểu tối 3 - Điểm nhấn 5 +Dark Style 3 - Accent 6 Kiểu tối 3 - Điểm nhấn 6 +Dark Style 4 Kiểu tối 4 +Dark Style 4 - Accent 1 Kiểu tối 4 - Điểm nhấn 1 +Dark Style 4 - Accent 2 Kiểu tối 4 - Điểm nhấn 2 +Dark Style 4 - Accent 3 Kiểu tối 4 - Điểm nhấn 3 +Dark Style 4 - Accent 4 Kiểu tối 4 - Điểm nhấn 4 +Dark Style 4 - Accent 5 Kiểu tối 4 - Điểm nhấn 5 +Dark Style 4 - Accent 6 Kiểu tối 4 - Điểm nhấn 6 +Dark appearance Giao diện tối +Darken Làm tối +Dash Nét đứt +Dash Dot Gạch chấm +Dashes Kiểu nét đứt +Data Dữ liệu +Date Ngày +Date & Ngày & +Date &\nTime Ngày &\ngiờ +Date & Time Ngày & giờ +Date and Time Ngày và giờ +Date and time Ngày và giờ +Decaying Wave Sóng tắt dần +Decimal Căn thập phân +Decimal Tab Tab thập phân +DeckCraft DeckCraft +DeckCraft Presentation (*.deckcraft) Bài thuyết trình DeckCraft (*.deckcraft) +DeckCraft can't export “{}” DeckCraft không thể xuất “{}” +DeckCraft can't find “{text}”. DeckCraft không tìm thấy “{text}”. +DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device. DeckCraft không dùng tài khoản: không cần đăng nhập và bài thuyết trình luôn nằm trên thiết bị của bạn. +DeckCraft prints through a PDF: it downloads, ready to print from your browser or PDF viewer. DeckCraft in thông qua tệp PDF: tệp sẽ được tải xuống để bạn in từ trình duyệt hoặc trình xem PDF. +DeckCraft prints through a PDF: it opens in your PDF viewer, where you choose the printer. DeckCraft in thông qua tệp PDF: tệp sẽ mở trong trình xem PDF để bạn chọn máy in. +DeckCraft's own format: everything is kept Định dạng riêng của DeckCraft: giữ nguyên mọi chi tiết +Decrease Font Size Giảm cỡ chữ +Decrease List Level Giảm cấp danh sách +Default tab stops (in): Điểm dừng tab mặc định (in): +Deflate Xẹp +Deflate Bottom Thắt đáy +Deflate Inflate Thắt rồi phồng +Deflate Inflate Deflate Thắt - phồng - thắt +Deflate Top Thắt đỉnh +Delay: Độ trễ: +Delete Xóa +Delete Columns Xóa cột +Delete Comment Xóa nhận xét +Delete Guide Xóa đường căn +Delete Layout Xóa bố cục +Delete Point Xóa điểm +Delete Repeated Word Xóa từ lặp lại +Delete Rows Xóa hàng +Delete Slide Xóa trang chiếu +Delete Table Xóa bảng +Delete Text Xóa văn bản +Delete cropped areas of pictures Xóa vùng ảnh đã cắt xén +Demote Hạ xuống +Depth Độ sâu +Desaturate Khử bão hòa +Design Thiết kế +Design\nIdeas Ý tưởng\nthiết kế +Design Ideas Ý tưởng thiết kế +Designer Trình thiết kế +Diagonal Down Border Viền chéo xuống +Diagonal Down Right Chéo xuống sang phải +Diagonal Up Border Viền chéo lên +Diagonal Up Right Chéo lên sang phải +Diamond Hình thoi +Diamonds From Bottom Hình thoi từ dưới lên +Diamonds From Left Hình thoi từ trái sang +Diamonds From Right Hình thoi từ phải sang +Diamonds From Top Hình thoi từ trên xuống +Dictionary: {name} Từ điển: {name} +Direction Hướng +Disappear Biến mất +Display Hiển thị +Display drawing guides on screen Hiển thị đường dẫn vẽ trên màn hình +Display grid on screen Hiển thị lưới trên màn hình +Display name Tên hiển thị +Dissolve Hoà tan +Dissolve In Tan hiện dần +Dissolve Out Tan biến dần +Distance Khoảng cách +Distance from ground Khoảng cách từ mặt phẳng +Distribute Phân bố +Distribute Columns Chia đều cột +Distribute Horizontally Phân bố theo chiều ngang +Distribute Rows Chia đều hàng +Distribute Vertically Phân bố theo chiều dọc +Distributed Căn đều phân tán +Divot Rãnh lõm +Dk edge Viền tối +Do not Autofit Không tự động vừa khít +Do not check spelling or grammar Không kiểm tra chính tả hoặc ngữ pháp +Do you want to save the changes you made to {what}? Bạn có muốn lưu các thay đổi đã thực hiện cho {what} không? +Don't Save Không lưu +Don't show on title slide Không hiển thị trên trang chiếu tiêu đề +Done Xong +Doors Cánh cửa +Dot Dấu chấm +Dotted Nét chấm +Double Nét đôi +Double Frame, Black Khung đôi, đen +Double Left Lật đôi sang trái +Double Right Lật đôi sang phải +Double Wave 1 Sóng đôi 1 +Doughnut Vành khuyên +Down Xuống +Down Left Xuống sang trái +Down Right Xuống sang phải +Download Tải xuống +Download the file Tải tệp xuống +Drape Màn rủ +Draw Vẽ +Draw Borders Vẽ đường viền +Draw Table Vẽ bảng +Drawing Bản vẽ +Drop Shadow Rectangle Hình chữ nhật bóng đổ +Drum Roll Tiếng trống dồn +Duplicate Nhân bản +Duplicate Selected Slides Nhân bản các trang chiếu đã chọn +Duplicate Slide Nhân bản trang chiếu +Duration handle {idx1} Tay cầm thời lượng {idx1} +Duration: Thời lượng: +Dynamic Content Nội dung động +Edit Chỉnh sửa +Edit\nData Sửa\nsố liệu +Edit\nShape Sửa\nhình +Edit Alt Text… Sửa văn bản thay thế… +Edit Data Sửa số liệu +Edit Header & Footer Sửa đầu trang & chân trang +Edit Master Sửa bản cái +Edit Photo Album… Sửa album ảnh… +Edit Points Sửa điểm neo +Edit Shape Sửa hình +Edit SmartArt Text Sửa văn bản SmartArt +Edit Text Chỉnh sửa văn bản +Edit the data: first row = series names, first column = categories. Chỉnh sửa dữ liệu: hàng đầu = tên chuỗi, cột đầu = hạng mục. +Editing Chỉnh sửa +Effect Hiệu ứng +Effect\nOptions Tùy chọn\nhiệu ứng +Effect Options Tùy chọn hiệu ứng +Effects Hiệu ứng +Eight Spokes Tám nan hoa +Email Address Địa chỉ email +Email address: Địa chỉ email: +Ember Than hồng +Emphasis Nhấn mạnh +End End +End\nReview Kết thúc\nxem lại +End Review Kết thúc xem lại +End Show Kết thúc trình chiếu +End arrow Mũi tên cuối +End of slide show Kết thúc trình chiếu +End of slide show, click to exit. Kết thúc bài thuyết trình, nhấp để thoát. +End size Kích cỡ cuối +End trim (s): Cắt cuối (giây): +English English +English (Australia) Tiếng Anh (Úc) +English (Canada) Tiếng Anh (Canada) +English (United Kingdom) Tiếng Anh (Anh) +English (United States) Tiếng Anh (Mỹ) +Enter Enter +Enter a scheme name. Hãy nhập tên bảng phối. +Enter a zoom percent Nhập tỷ lệ thu phóng (%) +Entrance Xuất hiện +Equation Phương trình +Equation Shapes Hình toán học +Eraser Tẩy +Exactly Chính xác +Exciting Sôi động +Exit Thoát +Exit Edit Points Thoát sửa điểm neo +Expand Mở rộng +Export Xuất +Export the presentation as: Xuất bài thuyết trình dưới dạng: +Exported {} Đã xuất {} +Export… Xuất… +Eyedropper Ống hút màu +Eyedropper: click the slide (Esc cancels) Ống nhỏ giọt: nhấp vào trang chiếu (Esc để hủy) +Fade Giảm dần +Fade Down Mờ dần xuống +Fade Left Mờ dần sang trái +Fade Right Mờ dần sang phải +Fade Up Mờ dần lên +Fade in (s): Hiện dần (giây): +Fade out (s): Tắt dần (giây): +Fall Over Đổ nghiêng +Ferris Wheel Vòng quay mặt trời +File Tệp +File (Alt+F) Tệp (Alt+F) +File › {} needs an open presentation Tệp › {} cần có bài thuyết trình đang mở +Files Tệp +Fill Tô nền +Fill & Line Tô & Đường nét +Fill Color Màu tô +Filled Radar Biểu đồ radar tô kín +Film Grain Hạt phim +Find Tìm +Find Next Tìm tiếp +Find and Replace Tìm và Thay thế +Find what: Tìm gì: +Find whole words only Chỉ tìm toàn bộ từ +Find… Tìm… +First Đầu +First Column Cột đầu tiên +First Slide Trang chiếu đầu tiên +First commit Lần commit đầu tiên +First line Dòng đầu +First {cap} slides {cap} trang chiếu đầu tiên +Fit Vừa khít +Fit slide to current window Vừa khít trang chiếu với cửa sổ hiện tại +Fit to Vừa với +Fit to\nWindow Vừa với\ncửa sổ +Fit to Window Vừa với cửa sổ +Fit to slide Vừa với trang chiếu +Flash Đèn flash +Flat Phẳng +Flip Lật +Flip Horizontal Lật ngang +Flip Vertical Lật dọc +Float Down Trôi xuống +Float In Trôi vào +Float Out Trôi ra +Float Up Trôi lên +Flood Ánh đèn pha +Flowchart Lưu đồ +Fly In Bay vào +Fly Out Bay ra +Fly Through Bay xuyên qua +Follow Path Theo đường dẫn +Follow the layout Theo bố cục +Followed Hyperlink Siêu liên kết đã truy cập +Font Phông chữ +Font Color Màu phông chữ +Font Size Cỡ chữ +Font name Tên phông chữ +Font size must be a number Cỡ chữ phải là một số +Fonts Phông chữ +Fonts in Presentation Phông chữ trong bài thuyết trình +Font… Phông chữ… +Football Bóng bầu dục +Footer Chân trang +Footers Chân trang +For rectangles and rounded rectangles Dành cho hình chữ nhật và hình chữ nhật bo góc +Foreground Nền trước +Format Định dạng +Format\nBackground Định dạng\nnền +Format\nPane Ngăn\nđịnh dạng +Format Background Định dạng nền +Format Background… Định dạng nền… +Format Painter (double-click to keep it on) Sao chép định dạng (nhấp đúp để giữ bật) +Format Pane Ngăn định dạng +Format Shape Định dạng hình +Format Shape… Định dạng hình… +Formatting State Trạng thái định dạng +Forward Tua tới +Four Spokes Bốn nan hoa +Fracture Vỡ mảnh +Fragment Mảnh vỡ +Frame Slides Đóng khung trang chiếu +Frame shape Hình dạng khung +Freeform Tự do +Freezing Lạnh giá +French (France) Tiếng Pháp (Pháp) +From Từ +From\nBeginning Từ\nđầu +From\nCurrent Slide Từ trang chiếu\nhiện tại +From Từ +From Beginning Từ đầu +From Bottom Từ dưới lên +From Bottom-Left Từ dưới-trái +From Bottom-Right Từ dưới-phải +From Current Slide Từ trang chiếu hiện tại +From Left Từ trái +From Right Từ phải +From Top Từ trên xuống +From Top-Left Từ trên-trái +From Top-Right Từ trên-phải +From bottom left Từ góc dưới bên trái +From bottom right Từ góc dưới bên phải +From center Từ tâm +From the top-left corner of the slide Từ góc trên bên trái của trang chiếu +From top left Từ góc trên bên trái +From top right Từ góc trên bên phải +Full Toàn phần +Full Page Slides Trang chiếu toàn trang +Funnel Hình phễu +G G +GIF GIF +Gallery Gallery +Gear Bánh răng +General Chung +Get Text Lấy văn bản +Glitter Lấp lánh +Glow Phát sáng +Glow Edges Viền phát sáng +Go to Đi tới +Go to slide {num} Đi tới trang chiếu {num} +Grab bag Hỗn hợp +Gradient Range - Accent 1 Dải chuyển sắc - Điểm nhấn 1 +Gradient Range - Accent 2 Dải chuyển sắc - Điểm nhấn 2 +Gradient Range - Accent 3 Dải chuyển sắc - Điểm nhấn 3 +Gradient Range - Accent 4 Dải chuyển sắc - Điểm nhấn 4 +Gradient Range - Accent 5 Dải chuyển sắc - Điểm nhấn 5 +Gradient Range - Accent 6 Dải chuyển sắc - Điểm nhấn 6 +Gradient fill Tô chuyển màu +Gradient stops Điểm dừng chuyển màu +Gradient… Chuyển sắc… +Grayscale Thang độ xám +Greek Tiếng Hy Lạp +Green Xanh lá +Grid Lưới +Grid\nOptions Tùy chọn\nlưới +Grid Matrix Ma trận lưới +Grid Options Tùy chọn lưới +Grid Options… Tùy chọn lưới… +Grid and Guides Lưới và đường căn +Gridlines Đường lưới +Group Gộp nhóm +Group Objects Nhóm đối tượng +Grow & Turn Phóng to & xoay +Grow/Shrink Phóng to/Thu nhỏ +Guides Đường căn +H H +HEX HEX +HSL HSL +HTML HTML +Half Một nửa +Half Circle Organization Chart Sơ đồ tổ chức bán nguyệt +Handout Tài liệu phát tay +Handout\nMaster Bản cái\ntài liệu phát tay +Handout Master Bản cái tài liệu phát tay +Handout Placeholders Khung giữ chỗ tài liệu phát tay +Handouts (1 slide per page) Tài liệu phát tay (1 trang chiếu mỗi trang) +Handouts (2 slides per page) Tài liệu phát tay (2 trang chiếu mỗi trang) +Handouts (3 slides per page) Tài liệu phát tay (3 trang chiếu mỗi trang) +Handouts (4 slides per page) Tài liệu phát tay (4 trang chiếu mỗi trang) +Handouts (6 slides per page) Tài liệu phát tay (6 trang chiếu mỗi trang) +Handouts (9 slides per page) Tài liệu phát tay (9 trang chiếu mỗi trang) +Hanging Thụt lề treo +Harbor Bến cảng +Hard edge Cạnh sắc +Harsh Gắt +Header Đầu trang +Header & Đầu trang & +Header &\nFooter Đầu trang &\nchân trang +Header & Footer Đầu trang & Chân trang +Header Row Hàng tiêu đề +Header and Footer Đầu trang và chân trang +Header and Footer… Đầu trang và chân trang… +Heading and body fonts for this presentation. Phông chữ tiêu đề và nội dung cho bài thuyết trình này. +Heading font Phông chữ tiêu đề +Heading sample Mẫu chữ tiêu đề +Heading: Tiêu đề: +Heart Hình trái tim +Heartbeat Nhịp tim +Heavy Đậm dày +Height Chiều cao +Height (in): Chiều cao (in): +Height: Chiều cao: +Help Trợ giúp +Hexagons From Bottom Lục giác từ dưới lên +Hexagons From Left Lục giác từ trái sang +Hexagons From Right Lục giác từ phải sang +Hexagons From Top Lục giác từ trên xuống +Hidden slides Trang chiếu ẩn +Hide Ẩn +Hide\nSlide Ẩn\ntrang chiếu +Hide All Ẩn tất cả +Hide Background Graphics Ẩn đồ họa nền +Hide During Show Ẩn khi trình chiếu +Hide Slide Ẩn trang chiếu +Hide background graphics Ẩn đồ họa nền +Hierarchy Phân cấp +High Cao +Highlight click Tô sáng khi nhấp +Highlighter Bút tô sáng +Histogram Histogram +Home Trang chủ +Honeycomb Tổ ong +Horizontal Ngang +Horizontal (in): Ngang (in): +Horizontal Bullet List Danh sách dấu đầu dòng ngang +Horizontal Figure 8 Hình số 8 nằm ngang +Horizontal Hierarchy Phân cấp ngang +Horizontal In Ngang hướng vào +Horizontal Out Ngang hướng ra +Horizontal Picture List Danh sách ảnh ngang +How would you describe this object and its context to someone who is blind or has low vision? Bạn sẽ mô tả đối tượng này và ngữ cảnh của nó như thế nào cho người khiếm thị hoặc thị lực kém? +Hyperlink Siêu liên kết +Hyperlink to: Siêu liên kết tới: +I I +Icon Gallery Thư viện biểu tượng +Icons Biểu tượng +Ideas Ý tưởng +Ignore All Bỏ qua tất cả +Illustrations Hình minh họa +Images Hình ảnh +In Bắt đầu +In Slightly Phóng nhẹ vào +In to Screen Center Vào giữa màn hình +In with Bounce Lao vào kèm nảy +Include hidden slides Bao gồm các trang chiếu ẩn +Increase Font Size Tăng cỡ chữ +Increase List Level Tăng cấp danh sách +Indent after text must be a number of inches Thụt lề sau văn bản phải là số inch +Indent before text must be a number of inches Thụt lề trước văn bản phải là số inch +Indentation Thụt lề +Inflate Phồng +Inflate Bottom Phồng đáy +Inflate Top Phồng đỉnh +Info Thông tin +Ink to Nét mực thành +Ink to\nMath Mực thành\ncông thức +Ink to\nShape Mực thành\nhình +Ink to\nText Nét mực\nthành chữ +Ink to Math Nét mực thành công thức +Ink to Math (coming soon) Nét mực thành công thức (sắp ra mắt) +Ink to Shape Mực thành hình +Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes Nét mực thành hình: chuyển các nét vẽ đường thẳng, hình chữ nhật, hình bầu dục và tam giác thành hình chuẩn +Ink to Text Nét mực thành chữ +Ink to Text (coming soon) Nét mực thành chữ (sắp ra mắt) +Inner Bên trong +Insert Chèn +Insert\nAbove Chèn\nlên trên +Insert\nBelow Chèn\nphía dưới +Insert\nLayout Chèn\nbố cục +Insert\nLeft Chèn\nbên trái +Insert\nPlaceholder Chèn khung\ngiữ chỗ +Insert\nRight Chèn\nbên phải +Insert Above Chèn lên trên +Insert Below Chèn xuống dưới +Insert Hyperlink Chèn siêu liên kết +Insert Layout Chèn bố cục +Insert Left Chèn sang trái +Insert Picture Chèn ảnh +Insert Picture in SmartArt Chèn ảnh vào SmartArt +Insert Picture in SmartArt… Chèn ảnh vào SmartArt… +Insert Placeholder Chèn khung giữ chỗ +Insert Right Chèn sang phải +Insert Shapes Chèn hình +Insert Synonym Chèn từ đồng nghĩa +Insert Table Chèn bảng +Insert Table… Chèn bảng… +Insert picture from file… Chèn ảnh từ tệp… +Inserted {} slides from {} Đã chèn {} trang chiếu từ {} +Insert… Chèn… +Inset Lõm +Inside Borders Viền trong +Inside Horizontal Border Viền ngang bên trong +Inside Vertical Border Viền dọc bên trong +Inspect Presentation Kiểm tra bài thuyết trình +Inspect Shape Kiểm tra hình +Inspect Slide Kiểm tra trang chiếu +Intense Effect Hiệu ứng mạnh +Interface size Kích thước giao diện +Intersect Giao nhau +Inverted Chevron Mũi tên chữ V ngược +Inverted Pyramid Kim tự tháp ngược +Inverted Triangle Tam giác ngược +Isometric bottom down Đẳng cự dưới hướng xuống +Isometric bottom up Đẳng cự dưới hướng lên +Isometric left down Đẳng cự trái hướng xuống +Isometric left up Đẳng cự trái hướng lên +Isometric off axis1 left Đẳng cự lệch trục 1 trái +Isometric off axis1 right Đẳng cự lệch trục 1 phải +Isometric off axis1 top Đẳng cự lệch trục 1 trên +Isometric off axis2 left Đẳng cự lệch trục 2 trái +Isometric off axis2 right Đẳng cự lệch trục 2 phải +Isometric off axis2 top Đẳng cự lệch trục 2 trên +Isometric off axis3 bottom Đẳng cự lệch trục 3 dưới +Isometric off axis3 left Đẳng cự lệch trục 3 trái +Isometric off axis3 right Đẳng cự lệch trục 3 phải +Isometric off axis4 bottom Đẳng cự lệch trục 4 dưới +Isometric off axis4 left Đẳng cự lệch trục 4 trái +Isometric off axis4 right Đẳng cự lệch trục 4 phải +Isometric right down Đẳng cự phải hướng xuống +Isometric right up Đẳng cự phải hướng lên +Isometric top down Đẳng cự trên hướng xuống +Isometric top up Đẳng cự trên hướng lên +Italic In nghiêng +JPEG JPEG +JPEG Picture (*.jpg) Ảnh JPEG (*.jpg) +JPEG image (current slide) Ảnh JPEG (trang chiếu hiện tại) +JSON JSON +Japanese Tiếng Nhật +Join our community on Discord Tham gia cộng đồng trên Discord +Join the ArtCraft community on Discord Tham gia cộng đồng ArtCraft trên Discord +Join type Kiểu nối góc +Justify Căn đều +Justify low Căn đều giãn nhẹ +Keep Source Formatting Giữ định dạng nguồn +Keep Text Only Chỉ giữ văn bản +Keeps the layout, fonts and pictures Giữ nguyên bố cục, phông chữ và hình ảnh +Landscape Hướng ngang +Landscape Orientation Hướng ngang +Language Ngôn ngữ +Language: Ngôn ngữ: +Large Lớn +Laser Bút laser +Last Cuối +Last Column Cột cuối cùng +Last Slide Trang chiếu cuối cùng +Last Slide Viewed Trang chiếu vừa xem +Last commit Lần commit cuối cùng +Launch Ra mắt +Layout Bố trí +Layout Placeholders Khung giữ chỗ bố cục +Layouts Bố cục +Learn Tìm hiểu +Learn\nMore Tìm hiểu\nthêm +Learn More Tìm hiểu thêm +Leave the slide background out: only what is on the slide is drawn Bỏ nền trang chiếu: chỉ xuất các đối tượng trên trang chiếu +Left Trái +Left Border Viền trái +Left Tab Tab trái +Legacy flat1 Phẳng cổ điển 1 +Legacy flat2 Phẳng cổ điển 2 +Legacy flat3 Phẳng cổ điển 3 +Legacy flat4 Phẳng cổ điển 4 +Legacy harsh1 Gắt cổ điển 1 +Legacy harsh2 Gắt cổ điển 2 +Legacy harsh3 Gắt cổ điển 3 +Legacy harsh4 Gắt cổ điển 4 +Legacy matte Mờ cổ điển +Legacy metal Kim loại cổ điển +Legacy normal1 Thường cổ điển 1 +Legacy normal2 Thường cổ điển 2 +Legacy normal3 Thường cổ điển 3 +Legacy normal4 Thường cổ điển 4 +Legacy oblique bottom Xiên cổ điển dưới +Legacy oblique bottom left Xiên cổ điển dưới trái +Legacy oblique bottom right Xiên cổ điển dưới phải +Legacy oblique front Xiên cổ điển chính diện +Legacy oblique left Xiên cổ điển trái +Legacy oblique right Xiên cổ điển phải +Legacy oblique top Xiên cổ điển trên +Legacy oblique top left Xiên cổ điển trên trái +Legacy oblique top right Xiên cổ điển trên phải +Legacy perspective bottom Phối cảnh cổ điển dưới +Legacy perspective bottom left Phối cảnh cổ điển dưới trái +Legacy perspective bottom right Phối cảnh cổ điển dưới phải +Legacy perspective front Phối cảnh cổ điển chính diện +Legacy perspective left Phối cảnh cổ điển trái +Legacy perspective right Phối cảnh cổ điển phải +Legacy perspective top Phối cảnh cổ điển trên +Legacy perspective top left Phối cảnh cổ điển trên trái +Legacy perspective top right Phối cảnh cổ điển trên phải +Legacy plastic Nhựa cổ điển +Legacy wireframe Khung dây cổ điển +Less Brightness Giảm độ sáng +Less Contrast Giảm độ tương phản +Letter Thư +LibreOffice LibreOffice +Light Sáng +Light Blue Xanh lam nhạt +Light Green Xanh lá nhạt +Light Style 1 Kiểu sáng 1 +Light Style 1 - Accent 1 Kiểu sáng 1 - Điểm nhấn 1 +Light Style 1 - Accent 2 Kiểu sáng 1 - Điểm nhấn 2 +Light Style 1 - Accent 3 Kiểu sáng 1 - Điểm nhấn 3 +Light Style 1 - Accent 4 Kiểu sáng 1 - Điểm nhấn 4 +Light Style 1 - Accent 5 Kiểu sáng 1 - Điểm nhấn 5 +Light Style 1 - Accent 6 Kiểu sáng 1 - Điểm nhấn 6 +Light Style 2 Kiểu sáng 2 +Light Style 2 - Accent 1 Kiểu sáng 2 - Điểm nhấn 1 +Light Style 2 - Accent 2 Kiểu sáng 2 - Điểm nhấn 2 +Light Style 2 - Accent 3 Kiểu sáng 2 - Điểm nhấn 3 +Light Style 2 - Accent 4 Kiểu sáng 2 - Điểm nhấn 4 +Light Style 2 - Accent 5 Kiểu sáng 2 - Điểm nhấn 5 +Light Style 2 - Accent 6 Kiểu sáng 2 - Điểm nhấn 6 +Light Style 3 Kiểu sáng 3 +Light Style 3 - Accent 1 Kiểu sáng 3 - Điểm nhấn 1 +Light Style 3 - Accent 2 Kiểu sáng 3 - Điểm nhấn 2 +Light Style 3 - Accent 3 Kiểu sáng 3 - Điểm nhấn 3 +Light Style 3 - Accent 4 Kiểu sáng 3 - Điểm nhấn 4 +Light Style 3 - Accent 5 Kiểu sáng 3 - Điểm nhấn 5 +Light Style 3 - Accent 6 Kiểu sáng 3 - Điểm nhấn 6 +Light Style 4 Kiểu sáng 4 +Light Style 4 - Accent 1 Kiểu sáng 4 - Điểm nhấn 1 +Light Style 4 - Accent 2 Kiểu sáng 4 - Điểm nhấn 2 +Light Style 4 - Accent 3 Kiểu sáng 4 - Điểm nhấn 3 +Light Style 4 - Accent 4 Kiểu sáng 4 - Điểm nhấn 4 +Light Style 4 - Accent 5 Kiểu sáng 4 - Điểm nhấn 5 +Light Style 4 - Accent 6 Kiểu sáng 4 - Điểm nhấn 6 +Light/Dark appearance Giao diện Sáng/Tối +Lighten Làm sáng +Lighting Ánh sáng +Line Đường thẳng +Line Color Màu đường +Line Drawing Nét vẽ chì +Line Spacing Giãn dòng +Line Spacing Options… Tùy chọn giãn dòng… +Line delta Chênh lệch số dòng +Line spacing Giãn dòng +Line spacing must be a number Khoảng cách dòng phải là một số +Line with Markers Đường có điểm đánh dấu +Linear Chuyển sang dạng tuyến tính +Linear Venn Sơ đồ Venn tuyến tính +Lines Số dòng +Lines +/− Dòng +/− +Lines added Dòng thêm vào +Lines deleted Dòng đã xóa +Link Liên kết +Links Liên kết +Link… Liên kết… +List Danh sách +List Commands Danh sách lệnh +Location Vị trí +Lock Khóa +Lock aspect ratio Khóa tỷ lệ khung hình +Long Dash Gạch dài +Long Dash Dot Gạch dài chấm +Look up Tra cứu +Loop Until Next Sound Lặp cho đến âm thanh tiếp theo +Loop continuously until 'Esc' Lặp liên tục cho đến khi nhấn 'Esc' +Loop de Loop Vòng nhào lộn +Loop until Stopped Lặp cho đến khi dừng +Loops Vòng lặp +Loose Thưa +Low Thấp +MIT OR Apache-2.0 · © 2026 ArtCraft Team and the DeckCraft contributors MIT OR Apache-2.0 · © 2026 Đội ngũ ArtCraft và những người đóng góp cho DeckCraft +MP4 MP4 +Manage Presentation Quản lý bài thuyết trình +Map Bản đồ +Margins (pt) Lề (pt) +Mark as decorative Đánh dấu là trang trí +Mark selected text as: Đánh dấu chữ đã chọn là: +Markdown Markdown +Marks Dấu in +Master Chính +Master Layout Bố cục bản cái +Master View Chế độ xem bản cái +Master Views Chế độ xem bản cái +Match case Khớp chữ hoa/thường +Match {n} of {total} on slide {slide}{where}. Kết quả {n}/{total} trên trang chiếu {slide}{where}. +Material Chất liệu +Math Toán học +Matrix Ma trận +Matte Matte +Maximize content (else ensure fit) Tối đa hóa nội dung (nếu tắt sẽ thu vừa khít) +Meadow Đồng cỏ +Media Phương tiện +Media Info Thông tin phương tiện +Medium Trung bình +Medium Style 1 Kiểu trung bình 1 +Medium Style 1 - Accent 1 Kiểu trung bình 1 - Điểm nhấn 1 +Medium Style 1 - Accent 2 Kiểu trung bình 1 - Điểm nhấn 2 +Medium Style 1 - Accent 3 Kiểu trung bình 1 - Điểm nhấn 3 +Medium Style 1 - Accent 4 Kiểu trung bình 1 - Điểm nhấn 4 +Medium Style 1 - Accent 5 Kiểu trung bình 1 - Điểm nhấn 5 +Medium Style 1 - Accent 6 Kiểu trung bình 1 - Điểm nhấn 6 +Medium Style 2 Kiểu trung bình 2 +Medium Style 2 - Accent 1 Kiểu trung bình 2 - Điểm nhấn 1 +Medium Style 2 - Accent 2 Kiểu trung bình 2 - Điểm nhấn 2 +Medium Style 2 - Accent 3 Kiểu trung bình 2 - Điểm nhấn 3 +Medium Style 2 - Accent 4 Kiểu trung bình 2 - Điểm nhấn 4 +Medium Style 2 - Accent 5 Kiểu trung bình 2 - Điểm nhấn 5 +Medium Style 2 - Accent 6 Kiểu trung bình 2 - Điểm nhấn 6 +Medium Style 3 Kiểu trung bình 3 +Medium Style 3 - Accent 1 Kiểu trung bình 3 - Điểm nhấn 1 +Medium Style 3 - Accent 2 Kiểu trung bình 3 - Điểm nhấn 2 +Medium Style 3 - Accent 3 Kiểu trung bình 3 - Điểm nhấn 3 +Medium Style 3 - Accent 4 Kiểu trung bình 3 - Điểm nhấn 4 +Medium Style 3 - Accent 5 Kiểu trung bình 3 - Điểm nhấn 5 +Medium Style 3 - Accent 6 Kiểu trung bình 3 - Điểm nhấn 6 +Medium Style 4 Kiểu trung bình 4 +Medium Style 4 - Accent 1 Kiểu trung bình 4 - Điểm nhấn 1 +Medium Style 4 - Accent 2 Kiểu trung bình 4 - Điểm nhấn 2 +Medium Style 4 - Accent 3 Kiểu trung bình 4 - Điểm nhấn 3 +Medium Style 4 - Accent 4 Kiểu trung bình 4 - Điểm nhấn 4 +Medium Style 4 - Accent 5 Kiểu trung bình 4 - Điểm nhấn 5 +Medium Style 4 - Accent 6 Kiểu trung bình 4 - Điểm nhấn 6 +Merge Gộp +Merge\nCells Gộp\nô +Merge\nShapes Gộp\nhình +Merge Cells Gộp ô +Merge Shapes Gộp hình +Merged PRs PR đã gộp +Metal Metal +Metal Frame Khung kim loại +Metal Oval Hình bầu dục kim loại +Metal Rounded Rectangle Hình chữ nhật bo góc kim loại +Middle Giữa +Mirror Phản chiếu +Mirror type Kiểu lật phản chiếu +Miter Vát xiên +Mode Chế độ +Model Mô hình +Models Mô hình +Moderate Frame, Black Khung vừa, đen +Moderate Frame, White Khung vừa, trắng +Month names and AM/PM are listed for English. Other languages list the numeric date and 24-hour time. Tên tháng và AM/PM hiển thị theo tiếng Anh. Các ngôn ngữ khác dùng định dạng ngày số và giờ 24h. +More Thêm +More Brightness Tăng độ sáng +More Colors… # Thêm màu… # +More Contrast Tăng độ tương phản +More presentations Thêm bài thuyết trình +More templates Thêm mẫu +More themes Thêm chủ đề +Morning Ban mai +Morph Biến hình (Morph) +Motion Chuyển động +Motion Paths Đường chuyển động +Mouse Click Nhấp chuột +Mouse Over Di chuột qua +Move Di chuyển +Move Down Di chuyển xuống +Move Guide Di chuyển đường căn +Move Handle Di chuyển điểm điều khiển +Move Point Di chuyển điểm +Move Section Di chuyển phân mục +Move Section Down Chuyển phân mục xuống +Move Section Up Chuyển phân mục lên +Move Slide Di chuyển trang chiếu +Move Up Di chuyển lên +Multidirectional Cycle Chu trình đa hướng +Multiple Nhiều +Mute Tắt tiếng +Name Tên +Name (A–Z) Tên (A–Z) +Name and Title Organization Chart Sơ đồ tổ chức tên và chức danh +Name: Tên: +Narrow Hẹp +Nested Target Mục tiêu lồng nhau +Neutron Quỹ đạo nguyên tử +New Mới +New\nComment Nhận xét\nmới +New\nSlide Trang chiếu\nmới +New\nWindow Cửa sổ\nmới +New Comment Nhận xét mới +New Photo Album… Album ảnh mới… +New Presentation Bài thuyết trình mới +New Slide Trang chiếu mới +New Slide (⇧⌘N) Trang chiếu mới (⇧⌘N) +New Text Box Hộp văn bản mới +New Window Cửa sổ mới +New Window needs a slide Cửa sổ mới cần có trang chiếu +New Window needs an open presentation Cửa sổ mới cần có bài thuyết trình đang mở +New show name: Tên trình chiếu mới: +Next Tiếp theo +Next Page Trang tiếp theo +Next Slide Trang chiếu tiếp theo +Next ▶ Tiếp ▶ +No Border Không viền +No Color Không màu +No Fill Không tô +No Glow Không phát sáng +No Outline Không viền +No Recolor Không đổi màu +No Reflection Không phản chiếu +No Shadow Không bóng đổ +No Soft Edges Không cạnh mềm +No Style, No Grid Không kiểu, không lưới +No Style, Table Grid Không kiểu, lưới bảng +No Transform Không biến đổi +No accessibility issues found. People with disabilities should not have difficulty reading this document. Không tìm thấy vấn đề trợ năng. Người khuyết tật có thể đọc tài liệu này thuận lợi. +No comments. Chưa có bình luận. +No contributor data was built into this copy. Bản sao này không tích hợp sẵn dữ liệu người đóng góp. +No differences left. Không còn điểm khác biệt nào. +No fill Không tô +No icons match. Không có biểu tượng phù hợp. +No line Không viền +No model credits were built into this copy. Bản sao này không tích hợp sẵn thông tin ghi công mô hình. +No notes. Chưa có ghi chú. +No pictures yet Chưa có ảnh nào +No presentation is open. Không có bài thuyết trình nào đang mở. +No recent presentations match “{query}”. Không có bài thuyết trình gần đây nào khớp với “{query}”. +No slides. Không có trang chiếu. +No sound: {err} Không có âm thanh: {err} +No synonyms for "{word}". Không có từ đồng nghĩa cho "{word}". +No themes or templates match “{query}”. Không có chủ đề hoặc mẫu nào khớp với “{query}”. +No {lang} dictionary is installed, so only common misspellings are found. Put a Hunspell .aff/.dic pair in {folder}. Chưa cài từ điển {lang}, nên chỉ phát hiện các lỗi chính tả phổ biến. Hãy đặt cặp tệp Hunspell .aff/.dic vào {folder}. +Nocturne Dạ khúc +None Không có +Normal Thường +Not a copy of this presentation: slides are matched by position. Không phải bản sao của bài thuyết trình này: các trang chiếu được đối chiếu theo vị trí. +Not saved yet Chưa lưu +Notes Ghi chú +Notes\nMaster Bản cái\nghi chú +Notes\nPage Trang\nghi chú +Notes Master Bản cái ghi chú +Notes Page Trang ghi chú +Notes Pages Trang ghi chú +Notes Placeholders Khung giữ chỗ ghi chú +Nothing to print: every slide in the range is hidden. Không có gì để in: mọi trang chiếu trong phạm vi đều đang ẩn. +Nudge Dịch chuyển nhỏ +Number Đánh số +Number of columns: Số cột: +Number of rows: Số hàng: +Numbered Đánh số +Numbering Đánh số +ODP ODP +OK OK +Object Center Tâm đối tượng +Object Color Màu đối tượng +Objects Đối tượng +Oblique bottom Xiên dưới +Oblique bottom left Xiên dưới trái +Oblique bottom right Xiên dưới phải +Oblique left Xiên trái +Oblique right Xiên phải +Oblique top Xiên trên +Oblique top left Xiên trên trái +Oblique top right Xiên trên phải +Offset X Lệch X +Offset Y Độ lệch Y +Offset bottom Đổ bóng lệch dưới +Offset left Đổ bóng lệch trái +Offset right Đổ bóng lệch phải +Offset top Đổ bóng lệch trên +On Click Khi nhấp chuột +On Click of {name} Khi nhấp vào {name} +On Click of… (none) Khi nhấp vào… (không có) +On Mouse Click Khi nhấp chuột +One Spoke Một nan hoa +Open Mở +Open Arrow Mũi tên hở +Open Dialog Mở hộp thoại +Open a presentation from a folder (Ctrl+O) Mở bài thuyết trình từ thư mục (Ctrl+O) +Open another window on this presentation Mở một cửa sổ khác cho bài thuyết trình này +Open file location Mở vị trí tệp +Open source under MIT OR Apache-2.0. Not affiliated with Microsoft. Mã nguồn mở theo giấy phép MIT HOẶC Apache-2.0. Không liên kết với Microsoft. +Opened the presentation as a PDF to print Đã mở bài thuyết trình dưới dạng PDF để in +Opening {}… Đang mở {}… +Opens in PowerPoint and other presentation apps Mở được trong PowerPoint và các ứng dụng trình chiếu khác +Opens on any device with a PDF viewer Mở được trên mọi thiết bị có trình xem PDF +Open… Mở… +Options Tùy chọn +Orange Cam +Orbit Quỹ đạo +Order Objects Thứ tự đối tượng +Ordinals (1st) with superscript Số thứ tự (1st) thành chữ chỉ số trên +Organization Chart Sơ đồ tổ chức +Orientation: Hướng: +Origami Gấp giấy Origami +Orthographic front Chính diện trực giao +Other Sound… Âm thanh khác… +Out Kết thúc +Out Slightly Thu nhẹ ra +Out from Screen Bottom Ra từ đáy màn hình +Out with Bounce Bay ra kèm nảy +Outer Bên ngoài +Outline Dàn ý +Outline\nView Chế độ\nDàn ý +Outline (*.txt) Dàn ý (*.txt) +Outline (.txt) Dàn ý (.txt) +Outline View Chế độ xem dàn ý +Outside Borders Viền ngoài +Oval Hình bầu dục +Oval ends Đầu hình bầu dục +PDF PDF +PDF (*.pdf) PDF (*.pdf) +PDF document (.pdf) Tài liệu PDF (.pdf) +PNG PNG +PNG Picture (*.png) Ảnh PNG (*.png) +PNG images (all slides) Ảnh PNG (tất cả trang chiếu) +PNG images (current slide) Ảnh PNG (trang chiếu hiện tại) +PPTX PPTX +PRs PR +Page Trang +Page Curl Cuộn trang +Page Number Số trang +Page Setup… Thiết lập trang… +Painter Sao chép định dạng +Pan Lia ngang +Pane Ngăn +Paper Giấy +Paragraph Đoạn +Paragraph… Đoạn… +Pareto Biểu đồ Pareto +Particles In Hạt tụ vào +Particles Out Hạt tan ra +Pass pictures to insert.photoAlbum Truyền ảnh vào lệnh insert.photoAlbum +Paste Dán +Paste (⌘V) Dán (⌘V) +Paste Formatting Dán định dạng +Paste Special Dán đặc biệt +Paste Special… Dán đặc biệt… +Paste and Match Formatting Dán và khớp định dạng +Paste or type an outline. Unindented lines become slide titles; tab-indented lines become bullets. Dán hoặc nhập dàn ý. Dòng không thụt lề sẽ thành tiêu đề trang chiếu; dòng thụt lề bằng Tab sẽ thành mục đầu dòng. +Pasted image.png Ảnh đã dán.png +Path Đường dẫn +Pattern fill Tô mẫu họa tiết +Pause Tạm dừng +Peanut Hình củ lạc +Peek In Hé vào +Peek Out Hé ra +Peel Off Bóc tách +Pen Color Màu bút +Pen Style Kiểu bút +Pen Weight Độ dày nét bút +Pencil Bút chì +Pencil Grayscale Phác thảo chì xám +People Con người +Percent: Phần trăm: +Perspective Phối cảnh +Perspective Shadow, White Bóng đổ phối cảnh, trắng +Perspective above Phối cảnh từ trên +Perspective above left facing Phối cảnh từ trên hướng sang trái +Perspective above right facing Phối cảnh từ trên hướng sang phải +Perspective below Phối cảnh từ dưới +Perspective contrasting left facing Phối cảnh tương phản hướng sang trái +Perspective contrasting right facing Phối cảnh tương phản hướng sang phải +Perspective front Phối cảnh chính diện +Perspective heroic extreme left facing Phối cảnh hùng vĩ cực đại hướng trái +Perspective heroic extreme right facing Phối cảnh hùng vĩ cực đại hướng phải +Perspective heroic left facing Phối cảnh hùng vĩ hướng trái +Perspective heroic right facing Phối cảnh hùng vĩ hướng phải +Perspective left Phối cảnh trái +Perspective relaxed Phối cảnh thoải +Perspective relaxed moderately Phối cảnh thoải vừa +Perspective right Phối cảnh phải +Photo Ảnh +Photo\nAlbum Album\nảnh +Photo Album Album ảnh +Photo Album options Tùy chọn Album ảnh +Photo Album… Album ảnh… +Photocopy Bản sao photocopy +Pick a design for this slide. Ideas are generated on your device. Chọn một thiết kế cho trang chiếu này. Các ý tưởng được tạo ngay trên thiết bị của bạn. +Pick a graphic; type one item per line. Chọn một kiểu đồ họa; nhập mỗi mục trên một dòng. +Picture Ảnh +Picture (PNG) Hình ảnh (PNG) +Picture Accent Process Quy trình điểm nhấn kèm ảnh +Picture Border Viền ảnh +Picture Caption List Danh sách chú thích ảnh +Picture Format Định dạng ảnh +Picture Style Gallery Thư viện kiểu ảnh +Picture Styles Kiểu ảnh +Picture from File… Ảnh từ tệp… +Picture layout Bố cục ảnh +Picture or texture fill Tô bằng ảnh hoặc kết cấu +Picture source Nguồn ảnh +Pictures Ảnh +Pictures are already at or below that resolution Các ảnh đã ở mức hoặc thấp hơn độ phân giải đó +Pictures in album Ảnh trong album +Pie Biểu đồ tròn +Pixels of this colour become transparent: Các điểm ảnh có màu này sẽ trở nên trong suốt: +Placeholder Khung giữ chỗ +Placeholder colour Màu khung giữ chỗ +Placeholders Khung giữ chỗ +Places Địa điểm +Plain Đơn giản +Plan Kế hoạch +Plan\nBuild\nLaunch Kế hoạch\nXây dựng\nRa mắt +Plan Build Launch Kế hoạch Xây dựng Ra mắt +Plastic Nhựa +Play Phát +Play Across Slides Phát xuyên suốt các trang chiếu +Play Full Screen Phát toàn màn hình +Play Narrations Phát lời thuyết minh +Play from Phát từ +Play from\nCurrent Slide Phát từ trang\nchiếu hiện tại +Play from\nStart Phát từ\nđầu +Play from Current Slide Phát từ trang chiếu hiện tại +Play from Start Phát từ đầu +Play the shape's media Phát phương tiện của hình +Play this slide's animations on the canvas Phát hiệu ứng động của trang chiếu này trên vùng vẽ +Playback Phát lại +Plus Dấu cộng +Point Type Loại điểm +Points Điểm +Polished Đánh bóng +Portrait Nằm dọc +Portrait Orientation Hướng dọc +Position Vị trí +Position Objects Vị trí đối tượng +Position and Size Vị trí và kích thước +Post Đăng +Poster Frame Khung hình đại diện +Powder Bột mịn +PowerPoint PowerPoint +PowerPoint Presentation (*.pptx) Bài thuyết trình PowerPoint (*.pptx) +PowerPoint Presentation (.pptx) Bài thuyết trình PowerPoint (.pptx) +Preferences Tùy chọn +Preferences… Tùy chọn… +Present Trình chiếu +Presentation Trình bày +Presentations Bài thuyết trình +Presentations and slide shows, rebuilt from scratch in pure Rust. Ứng dụng thiết kế bài thuyết trình và trình chiếu, được xây dựng mới hoàn toàn bằng Rust thuần. +Presentations you open show up here. Use Open to browse for one. Các bài thuyết trình bạn đã mở sẽ xuất hiện tại đây. Hãy dùng Mở để duyệt tìm tệp. +Presented by a speaker (full screen) Diễn giả trình bày (toàn màn hình) +Presenter Diễn giả +Presenter\nView Chế độ\nDiễn giả +Presenter View Chế độ xem diễn giả +Presenter View — DeckCraft Chế độ xem diễn giả — DeckCraft +Preserve Giữ hồ sơ nhúng +Preset gradients Chuyển màu đặt sẵn +Presets Mẫu đặt sẵn +Prestige Sang trọng +Preview Xem thử +Preview Animations Xem trước hiệu ứng động +Previewing animations Đang xem trước hiệu ứng động +Previous Trước đó +Previous Page Trang trước +Previous Slide Trang chiếu trước +Print In +Print All Slides In tất cả trang chiếu +Print Current Slide In trang chiếu hiện tại +Print Hidden Slides In các trang chiếu ẩn +Print layout: Bố cục in: +Print them for your audience In tài liệu phát tay cho khán giả +Print… In… +Process Quy trình +Product Information Thông tin sản phẩm +Program: Chương trình: +Promote Nâng lên +Proofing Hiệu đính +Properties Thuộc tính +Pulse Nhịp đập +Punctuation Dấu câu +Purple Tím +Push Đẩy +Put a picture in the selected node's picture placeholder Đặt ảnh vào khung giữ chỗ ảnh của nút đang chọn +Pyramid Kim tự tháp +Quick Nhanh +Quick\nStyles Kiểu\nnhanh +Quick Access Toolbar Thanh công cụ truy cập nhanh +Quick Styles Kiểu nhanh +R R +RGB RGB +Radar Biểu đồ radar +Radial Hướng tâm +Random Ngẫu nhiên +Random Bars Thanh ngẫu nhiên +Reading Đọc +Reading\nView Chế độ\nĐọc +Reading View Chế độ xem đọc +Reading the file: {} s Đang đọc tệp: {} giây +Real name Tên thật +Recent Gần đây +Recolor Tô màu lại +Recolor Gallery Thư viện đổi màu +Record Ghi hình +Recording narration needs audio capture (coming soon) Ghi âm lời thuyết minh cần tính năng thu âm (sắp ra mắt) +Recover Unsaved Presentations Khôi phục bài thuyết trình chưa lưu +Recovered Presentations Bài thuyết trình đã khôi phục +Rectangle Hình chữ nhật +Rectangles Hình chữ nhật +Rectangular Hình chữ nhật +Red Đỏ +Red Pen Bút đỏ +Redo Làm lại +Reflected Bevel, White Vát cạnh phản chiếu, trắng +Reflected Rounded Rectangle Hình chữ nhật bo góc phản chiếu +Reflection Phản chiếu +Regroup Nhóm lại +Rehearse Tập dượt +Rehearse\nTimings Tập dượt\nthời gian +Rehearse Timings Tập dượt thời gian +Relationship Mối quan hệ +Relaxed inset Lõm thoải +Remove Gỡ bỏ +Remove\nBackground Xóa\nnền +Remove All Animations Xóa tất cả hiệu ứng động +Remove All Sections Xóa tất cả phân mục +Remove Animation Xóa hiệu ứng động +Remove Background Gỡ nền ảnh +Remove Less (tighter) Xóa ít hơn (chặt hơn) +Remove More (looser) Xóa nhiều hơn (rộng hơn) +Remove Section Xóa phân mục +Remove Section & Slides Xóa phân mục & trang chiếu +Remove Shape Xóa hình +Remove bullets and numbering Bỏ dấu đầu dòng và đánh số +Remove from Quick Access Toolbar Gỡ khỏi thanh công cụ truy cập nhanh +Remove from Recent Xóa khỏi Gần đây +Remove stop Xóa điểm dừng +Remove the picture style Bỏ kiểu khung ảnh +Rename Đổi tên +Rename Layout Đổi tên bố cục +Rename Section Đổi tên phân mục +Rename Section… Đổi tên phân mục… +Rename Slide Đổi tên trang chiếu +Render Slide Kết xuất trang chiếu +Reopen Mở lại +Reorder Animation Sắp xếp lại hiệu ứng động +Reorder Objects Sắp xếp lại đối tượng +Repeated word Từ lặp lại +Replace Thay thế +Replace All Thay thế tất cả +Replace Font Thay thế phông chữ +Replace Fonts Thay thế phông chữ +Replace Fonts… Thay thế phông chữ… +Replace fractions (1/2) with fraction characters (½) Thay phân số (1/2) bằng ký tự phân số (½) +Replace straight quotes with smart quotes Thay dấu ngoặc kép thẳng bằng dấu ngoặc kép thông minh +Replace text as you type Thay thế văn bản khi nhập +Replace with: Thay bằng: +Replace: Thay thế: +Replaced {n} occurrence(s). Đã thay thế {n} vị trí. +Replaced “{from}” with “{to}”. Đã thay phông “{from}” bằng “{to}”. +Replace… Thay thế… +Reply Trả lời +Reply… Trả lời… +Rescan Installed Fonts Quét lại phông chữ đã cài +Reset Đặt lại +Reset 3-D Đặt lại 3-D +Reset Background Đặt lại nền +Reset Crop Đặt lại cắt xén +Reset Effects Đặt lại hiệu ứng +Reset Picture Đặt lại ảnh +Reset Slide Đặt lại trang chiếu +Reset Slide Background Đặt lại nền trang chiếu +Reset Style Đặt lại kiểu +Reset to Defaults Đặt lại về mặc định +Resize shape to fit text Tự đổi cỡ hình cho vừa văn bản +Resolution Độ phân giải +Resolve Giải quyết +Resolve Thread Giải quyết luồng bình luận +Resume Tiếp tục +Reuse Slides (Keep Source Formatting)… Tái sử dụng trang chiếu (Giữ định dạng nguồn)… +Reuse Slides needs the desktop app Tái sử dụng trang chiếu hiện cần ứng dụng máy tính +Reuse Slides… Tái sử dụng trang chiếu… +Reveal Hé lộ +Reverse the order Đảo ngược thứ tự +Revert Hoàn nguyên +Review Xem lại +Revisions Các bản sửa đổi +Rewind after Playing Tua lại sau khi phát +Ribbon Tab Thẻ thanh công cụ +Riblet Gờ sọc +Right Phải +Right Border Viền bên phải +Right Tab Tab phải +Right Triangle Tam giác vuông +Right-click any ribbon button to add it. Nhấp chuột phải vào bất kỳ nút nào trên thanh công cụ để thêm vào đây. +Right-to-Left Text Direction Hướng chữ từ phải sang trái +Ring Inside Vòng tròn mặt trong +Ring Outside Vòng tròn mặt ngoài +Ripple Gợn sóng +Rise Up Bay vọt lên +Rotate Xoay +Rotate Left Xoay trái +Rotate Left 90° Xoay trái 90° +Rotate Right Xoay phải +Rotate Right 90° Xoay phải 90° +Rotate all text 270° Xoay toàn bộ chữ 270° +Rotate all text 90° Xoay toàn bộ chữ 90° +Rotate or Flip Xoay hoặc lật +Rotate with shape Xoay cùng hình +Rotated, White Khung xoay nghiêng, trắng +Rotation Xoay +Rotation (°): Góc xoay (°): +Rotation {rot}°, brightness {brightness}, contrast {contrast} Góc xoay {rot}°, độ sáng {brightness}, độ tương phản {contrast} +Round Tròn +Round Dot Chấm tròn +Rounded Tròn +Rounded Diagonal Corner, White Bo góc chéo, trắng +Rows & Columns Hàng & Cột +Ruler Thước +Run program: Chạy chương trình: +S S +S Curve 1 Đường cong chữ S 1 +S Curve 2 Đường cong chữ S 2 +SVG SVG +Sample deck: Northern Lights Bài thuyết trình mẫu: Bắc Cực Quang +Saturation Độ bão hòa +Save Lưu +Save As Lưu thành +Save As… Lưu thành… +Save to Data Lưu vào dữ liệu +Saved Đã lưu +Saved {} Đã lưu {} +Saving a copy needs a file picker, which this window doesn't have. Lưu bản sao cần hộp thoại chọn tệp, hiện không khả dụng trong cửa sổ này. +Scale X Tỷ lệ X +Scale Y Tỷ lệ Y +Scale height Tỷ lệ chiều cao +Scale height (%): Tỷ lệ chiều cao (%): +Scale width Tỷ lệ chiều rộng +Scale width (%): Tỷ lệ chiều rộng (%): +Scatter Phân tán +Scheme name Tên bảng phối +ScreenTip: Chú giải: +Screenshot Ảnh chụp màn hình +Screenshot (not available yet) Ảnh chụp màn hình (chưa khả dụng) +Search commands (⇧⌘P) Tìm lệnh (⇧⌘P) +Search commands… Tìm lệnh… +Search for a file Tìm tệp +Search for a word Tìm một từ +Search for themes and templates Tìm chủ đề và mẫu +Search icons Tìm biểu tượng +Seconds per slide: enter {min} to {max} Số giây mỗi trang chiếu: nhập từ {min} đến {max} +Section Phân mục +Segmented Cycle Chu trình phân đoạn +Segmented Pyramid Kim tự tháp phân đoạn +Select Chọn +Select All Chọn tất cả +Select a SmartArt graphic to edit its text. Chọn một đồ họa SmartArt để sửa văn bản. +Select a chart first. Hãy chọn một biểu đồ trước. +Select a picture to rotate it or change brightness and contrast Chọn một ảnh để xoay hoặc chỉnh độ sáng và độ tương phản +Select a shape to format it. Chọn một hình để định dạng. +Select a word on the slide, or type one above. Chọn một từ trên trang chiếu hoặc nhập vào ô phía trên. +Select an object on the slide and add an animation from the Animations tab. Chọn một đối tượng trên trang chiếu và thêm hiệu ứng từ thẻ Hiệu ứng động. +Selection Phần đã chọn +Selection\nPane Ngăn\nchọn +Selection Pane Khung chọn đối tượng +Selection Pane… Ngăn chọn… +Send Chuyển xuống +Send\nBackward Chuyển\nxuống dưới +Send Backward Chuyển ra sau +Send to Back Đưa xuống dưới cùng +Sentence case. Viết hoa đầu câu. +Sepia Nâu sepia +Set Đặt +Set Text Đặt văn bản +Set Transparent Color Đặt màu trong suốt +Set Up Thiết lập +Set Up\nSlide Show Thiết lập\ntrình chiếu +Set Up Show… Thiết lập trình chiếu… +Set Up Slide Show Thiết lập trình chiếu +Set Up Slide Show… Thiết lập trình chiếu… +Set Zoom Đặt thu phóng +Set as Default Shape Đặt làm hình mặc định +Settings Cài đặt +Several slides on each page of a PDF Nhiều trang chiếu trên mỗi trang PDF +Shading Tô nền +Shadow Bóng đổ +Shape Hình +Shape\nStyles Kiểu\nhình +Shape Effects Hiệu ứng hình +Shape Fill Tô nền hình +Shape Format Định dạng hình +Shape Gallery Thư viện hình +Shape Outline Viền hình +Shape Styles Kiểu hình +Shape {shape_id} Hình {shape_id} +Shapes Các hình +Show Hiện +Show\nComments Hiện\nnhận xét +Show All Hiện tất cả +Show Comments Hiện nhận xét +Show Media Controls Hiện điều khiển phương tiện +Show Slide Hiện trang chiếu +Show smart guides Hiện đường căn thông minh +Show type Kiểu trình chiếu +Show without animation Trình chiếu không có hiệu ứng động +Show without narration Trình chiếu không có lời thuyết minh +Show/Hide Hiện/Ẩn +Shred Xé mảnh +Shrink & Turn Thu nhỏ & xoay +Shrink text on overflow Thu nhỏ chữ khi tràn khung +Simple Fill Tô đơn giản +Simple Frame, Black Khung đơn giản, đen +Simple Frame, White Khung đơn giản, trắng +Simple, white Đơn giản, trắng +Sine Wave Sóng hình sin +Single Đơn +Single Left Lật đơn sang trái +Single Right Lật đơn sang phải +Sink Down Chìm xuống +Size Cỡ +Size & Properties Kích thước & Thuộc tính +Size (% of text): Kích cỡ (% chữ): +Size and Position Kích thước và vị trí +Size and Position… Kích thước và vị trí… +Size: Kích thước: +Slant Down Nghiêng xuống +Slant Up Nghiêng lên +Slate Đá phiến +Slide Trang chiếu +Slide\nMaster Bản cái\ntrang chiếu +Slide\nNumber Số\ntrang chiếu +Slide\nSize Kích cỡ\ntrang chiếu +Slide\nSorter Sắp xếp\ntrang chiếu +Slide Center Tâm trang chiếu +Slide Image Ảnh trang chiếu +Slide Master Bản cái trang chiếu +Slide Number Số trang chiếu +Slide Show Trình chiếu +Slide Size Kích cỡ trang chiếu +Slide Sorter Sắp xếp trang chiếu +Slide inserted after {slide} Đã chèn trang chiếu sau trang {slide} +Slide number Số trang chiếu +Slide timings recorded. Đã ghi lại thời gian trang chiếu. +Slide {current} of {total} Trang chiếu {current} / {total} +Slide {i} of {n} Trang chiếu {i} / {n} +Slide {n} Trang chiếu {n} +Slide {slide1} changed Trang chiếu {slide1} đã thay đổi +Slide {slide1} deleted Trang chiếu {slide1} đã xóa +Slide {slide_time} Total {total_time} Trang chiếu {slide_time} Tổng {total_time} +Slide {} Trang chiếu {} +Slide {} of {} Trang chiếu {} / {} +Slides Trang chiếu +Slides (e.g. 1,3,5): Trang chiếu (VD: 1,3,5): +Slides From Trang chiếu từ +Slides Per Số trang chiếu mỗi +Slides Per\nPage Số trang chiếu\nmỗi trang +Slides Per Page Số trang chiếu mỗi trang +Slides from Outline Trang chiếu từ dàn ý +Slides from Outline… Trang chiếu từ dàn ý… +Slides sized for: Kích cỡ trang chiếu cho: +Slides: Trang chiếu: +Slides: choose a range from 1 to {n} Trang chiếu: chọn khoảng từ 1 đến {n} +Slide… Trang chiếu… +Slope Độ dốc +Small Nhỏ +Small caps Chữ hoa nhỏ +SmartArt SmartArt +SmartArt Design Thiết kế SmartArt +SmartArt Styles Kiểu SmartArt +Smooth Corners Bo mượt góc +Smooth Point Điểm mượt +Smoothly Mượt mà +Smoothly From Left Mượt mà từ trái sang +Smoothly From Right Mượt mà từ phải sang +Snap Bắt dính +Snap objects to grid Bắt dính đối tượng vào lưới +Snap objects to other objects Bắt dính đối tượng với đối tượng khác +Snip Diagonal Corner, White Vát góc chéo, trắng +Soft Mềm +Soft Edge Oval Hình bầu dục mờ viền +Soft Edge Rectangle Hình chữ nhật mờ viền +Soft Edges Cạnh mềm +Soft edge Viền mềm +Soft round Bo tròn mềm +Softmetal Kim loại dịu +Solid Liền +Solid fill Tô đặc +Solid line Đường nét liền +Sort Sắp xếp +Sorter Sắp xếp +Sound Âm thanh +Sound: Âm thanh: +Source on GitHub Mã nguồn trên GitHub +Space Dấu cách +Spacing Khoảng cách +Spacing (pt): Khoảng cách (pt): +Spacing after Khoảng cách sau +Spacing after must be a number Khoảng cách sau phải là một số +Spacing before Khoảng cách trước +Spacing before must be a number Khoảng cách trước phải là một số +Special Đặc biệt +Special indent must be a number of inches Thụt lề đặc biệt phải là số inch +Specify the company Nhập tên công ty +Specify the subject Nhập chủ đề +Spelling Chính tả +Spelling Dictionary Từ điển chính tả +Spelling is checked for languages marked (dictionary). Chính tả được kiểm tra đối với các ngôn ngữ có đánh dấu (từ điển). +Spin Xoáy +Spinner Xoay tròn +Spiral Left Xoắn ốc sang trái +Spiral Right Xoắn ốc sang phải +Split Chia cửa sổ +Split\nCells Tách\nô +Split Cells Chia ô +Spring Lò xo +Square Vuông +Square Dot Chấm vuông +Stacked Xếp chồng +Stacked Area Biểu đồ miền chồng +Stacked Bar Thanh xếp chồng +Stacked Column Cột xếp chồng +Stacked Line Biểu đồ đường chồng +Stacked Venn Sơ đồ Venn xếp chồng +Stairs Down Bậc thang đi xuống +Standard (4:3) Tiêu chuẩn (4:3) +Standard Colors Màu chuẩn +Stars and Banners Ngôi sao và biểu ngữ +Start Bắt đầu +Start After Previous Bắt đầu sau hiệu ứng trước +Start Automatically Tự động bắt đầu +Start On Click Bắt đầu khi nhấp chuột +Start Slide Show Bắt đầu trình chiếu +Start With Previous Bắt đầu cùng hiệu ứng trước +Start a conversation Bắt đầu cuộc thảo luận +Start at: Bắt đầu từ: +Start from Beginning Bắt đầu từ đầu +Start time (s): Thời gian bắt đầu (giây): +Start: Bắt đầu: +Start: {}s, End: {}s Bắt đầu: {}s, Kết thúc: {}s +Starter Templates Mẫu khởi đầu +Stealth Mũi tên én +Stealth end Đuôi mũi tên én +Step Up Process Quy trình bậc thang đi lên +Still opening {} Vẫn đang mở {} +Stock Chứng khoán +Stop Dừng +Stopped opening {} Đã dừng mở {} +Straight Point Điểm thẳng +Strikethrough Gạch ngang chữ +Strips Dải sọc +Strips In Dải sọc hướng vào +Strips Out Dải sọc hướng ra +Style {i} Kiểu {i} +Styles Kiểu +Subject Chủ đề +Subscript Chỉ số dưới +Subtle Tinh tế +Subtle Effect Hiệu ứng nhẹ +Subtract Trừ +Sunburst Biểu đồ vòng đồng tâm +Sunrise Bình minh +Sunset Hoàng hôn +Superscript Chỉ số trên +Surface Bề mặt +Swedish Tiếng Thụy Điển +Switch Hoán đổi +Swivel Xoay trục dọc +Swoosh Lướt vút +Symbol Ký hiệu +Symbols Các ký hiệu +System Default Mặc định hệ thống +Tab Tab +Tab stop position (in): Vị trí điểm dừng tab (in): +Tab stops: Điểm dừng tab: +Table Bảng +Table Design Thiết kế bảng +Table Eraser Tẩy viền bảng +Table Layout Bố trí bảng +Table List Danh sách bảng +Table Style Options Tùy chọn kiểu bảng +Table Styles Các kiểu bảng +Tables Bảng +Table… Bảng… +Tabs Điểm dừng tab +Tabs… Mốc tab… +Tags Thẻ +Task Pane Ngăn tác vụ +Teardrop Giọt nước +Teeter Bập bênh +Templates Mẫu +Text Văn bản +Text\nBox Hộp\nvăn bản +Text\nPane Ngăn\nvăn bản +Text Box Hộp văn bản +Text Box Margins Lề hộp văn bản +Text Cycle Chu trình văn bản +Text Direction Hướng văn bản +Text Effects Hiệu ứng chữ +Text Fill Tô chữ +Text Highlight Color Màu tô sáng chữ +Text Options Tùy chọn văn bản +Text Outline Viền chữ +Text Pane Ngăn văn bản +Text Shadow Bóng chữ +Text Transform Biến dạng chữ +Text Transparency Độ trong suốt chữ +Text box: Hộp văn bản: +Text boxes have no picture options Hộp văn bản không có tùy chọn hình ảnh +Text color 2 Dark Màu chữ 2 Đậm +Text color 2 Light Màu chữ 2 Nhạt +Text is longer than 400 characters Văn bản dài quá 400 ký tự +Text/Background - Dark 1 Chữ/Nền - Tối 1 +Text/Background - Dark 2 Chữ/Nền - Tối 2 +Text/Background - Light 1 Chữ/Nền - Sáng 1 +Text/Background - Light 2 Chữ/Nền - Sáng 2 +That file is empty Tệp đó đang trống +That picture is larger than 32 MB Ảnh đó lớn hơn 32 MB +The Eyedropper takes colours from a slide Ống nhỏ giọt lấy màu từ một trang chiếu +The current slide as a compressed picture Trang chiếu hiện tại dưới dạng ảnh nén +The current slide as a picture Trang chiếu hiện tại dưới dạng ảnh +The picture list is invalid Danh sách ảnh không hợp lệ +The presentation has no sections. Add one with Home › Section. Bài thuyết trình chưa có phân mục. Hãy thêm bằng Trang đầu › Phân mục. +The presentation keeps its own file Bài thuyết trình giữ nguyên tệp riêng +The slide titles and bullet text as plain text Tiêu đề trang chiếu và các mục đầu dòng dưới dạng văn bản thuần +The spelling check is complete. No issues found. Đã kiểm tra chính tả xong. Không phát hiện lỗi nào. +The version, the people and models who built it, and where to find us. Phiên bản, những người và mô hình đã xây dựng ứng dụng, cùng nơi liên hệ. +The web build inserts album pictures from data passed to insert.photoAlbum Bản web chèn ảnh album từ dữ liệu truyền vào lệnh insert.photoAlbum +Theme Colors Màu chủ đề +Theme Gallery Thư viện chủ đề +Themed Style 1 - Accent 1 Kiểu theo chủ đề 1 - Điểm nhấn 1 +Themed Style 2 - Accent 1 Kiểu theo chủ đề 2 - Điểm nhấn 1 +Themes Chủ đề +Themes (whole presentation) Chủ đề (toàn bộ bài thuyết trình) +There are no unsaved changes. Không có thay đổi nào chưa lưu. +There are no unsaved presentations to recover Không có bài thuyết trình chưa lưu nào để khôi phục +Thesaurus Từ điển đồng nghĩa +Thick Matte, Black Khung mờ dày, đen +Thick Thin Dày - mảnh +Thin Thick Mảnh - dày +This Document Tài liệu này +This presentation has never been saved. Bài thuyết trình này chưa từng được lưu. +This presentation has no custom shows (Slide Show ▸ Custom Slide Show). Bài thuyết trình này chưa có trình chiếu tùy chỉnh (Trình chiếu ▸ Trình chiếu tùy chỉnh). +This presentation has unsaved changes. Revert goes back to the saved file. Bài thuyết trình này có thay đổi chưa lưu. Hoàn nguyên sẽ quay về tệp đã lưu. +This slide has no animations Trang chiếu này không có hiệu ứng động +Three Spokes Ba nan hoa +Three a page leaves lines for notes Ba trang chiếu mỗi trang có dòng kẻ để ghi chú +Three pt Ba pt +Through Black Qua nền đen +Through Black From Left Qua nền đen từ trái sang +Through Black From Right Qua nền đen từ phải sang +Tight Khít +Tile picture as texture Lát ảnh làm kết cấu nền +Time Thời gian +Timing Định thời +Timing bar {idx1} Thanh thời gian {idx1} +Timings Thời gian +Title Tiêu đề +Titled Matrix Ma trận có tiêu đề +To All Cho tất cả +To Bottom Xuống dưới +To Left Sang trái +To Right Sang phải +To Top Lên trên +Top Trên +Top Border Viền trên +Top Left Trên trái +Top Right Trên phải +Top bevel Vát cạnh trên +Top left Trên trái +Top right Trên phải +Total Row Hàng tổng +Transform Biến đổi +Transition Chuyển tiếp +Transition Gallery Thư viện chuyển tiếp +Transition to This Slide Chuyển tiếp sang trang chiếu này +Transitions Chuyển tiếp +Translate Dịch +Translation needs a language model (not bundled) Tính năng dịch cần mô hình ngôn ngữ (không đi kèm) +Translucent powder Bột bán trong suốt +Transparency Độ trong suốt +Transparent background Nền trong suốt +Trapezoid List Danh sách hình thang +Treemap Biểu đồ bản đồ cây +Triangle Tam giác +Trigger Kích hoạt +Trim Cắt bỏ +Trim Media Cắt phương tiện +Triple Nét ba +Turns Đường rẽ +Two Spokes Hai nan hoa +Two pt Hai pt +Type Loại +Type Text Nhập văn bản +Type an equation in linear form (e.g. a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). Nhập công thức ở dạng tuyến tính (VD: a^2+b^2=c^2, x=(-b±√(b^2-4ac))/2a). +Type and organise the diagram's text Nhập và sắp xếp văn bản của sơ đồ +Type the text for the new text box Nhập văn bản cho hộp văn bản mới +Type your text here Nhập văn bản tại đây +U U +UPPERCASE CHỮ HOA +URL… URL… +Uncover Kéo mở +Underline Gạch chân +Undo Hoàn tác +Ungroup Tách nhóm +Union Phép hợp +Unlock Mở khóa +Up Lên +Up Left Lên sang trái +Up Right Lên sang phải +Update Cập nhật +Update automatically Tự động cập nhật +Use Destination Theme Dùng chủ đề đích +Use Timings Dùng thời gian đã lưu +Use timings, if present Dùng thời gian đã lưu (nếu có) +User Information Thông tin người dùng +Username Tên đăng nhập +Variant Gallery Thư viện biến thể +Variants Biến thể +Version Phiên bản +Vertical Dọc +Vertical (in): Dọc (in): +Vertical Box List Danh sách hộp dọc +Vertical Bullet List Danh sách dấu đầu dòng dọc +Vertical Figure 8 Hình số 8 thẳng đứng +Vertical In Dọc hướng vào +Vertical Out Dọc hướng ra +Vertical Picture List Danh sách ảnh dọc +Vertical Process Quy trình dọc +Vertical alignment Căn chỉnh dọc +Very Loose Rất thưa +Very Tight Rất khít +Very loose Rất thưa +Very tight Rất khít +Video Video +Video Format Định dạng video +Video from File… Video từ tệp… +View Xem +Volume Tập +Vortex Xoáy lốc +W W +Warm matte Mờ ấm +Warp Biến dạng +Washout Mờ nhạt +Waterfall Biểu đồ thác nước +Wave Lượn sóng +Wave 1 Sóng 1 +Wave 2 Sóng 2 +Wave 4 Sóng 4 +Wavy Lượn sóng +Web Page or File Trang web hoặc tệp +Wedge Hình nêm +Weight Độ dày nét +What an outing set out to see, and what it actually found Mục tiêu chuyến khảo sát đề ra và những gì thực tế đã khám phá +Wheel Bánh xe +White Trắng +White Outline Viền trắng +White on Đang bật màn trắng +Whoosh Tiếng gió vút +Wide Rộng +Widescreen (16:9) Màn hình rộng (16:9) +Width Chiều rộng +Width (in): Chiều rộng (in): +Width: Chiều rộng: +Width: Rộng: +Width: enter {min} to {max} pixels Chiều rộng: nhập từ {min} đến {max} điểm ảnh +Wind Gió +Window Cửa sổ +Wipe Quét +With Bằng +With Previous Cùng hiệu ứng trước +With: Bằng: +WordArt WordArt +WordArt Styles Kiểu WordArt +WordArt style {n} Kiểu WordArt {n} +Words Từ +Wrap Text in Shape Ngắt dòng văn bản trong hình +Wrap text in shape Ngắt dòng văn bản trong hình +X X +X Rotation Xoay X +XLSX XLSX +Y Y +Y Rotation Xoay Y +Yellow Vàng +Your changes will be lost if you don't save them. Các thay đổi sẽ bị mất nếu bạn không lưu. +Your name (comments): Tên của bạn (bình luận): +Z Z +Z Rotation Xoay Z +Zigzag Zigzag +Zoom Thu phóng +Zoom In Phóng to +Zoom Out Thu nhỏ +Zoom and Rotate Thu phóng và xoay +Zoom on Đang bật thu phóng +Zoom to Thu phóng tới +Zoom… Thu phóng… +[No Sound] [Không có âm thanh] +[Stop Previous Sound] [Dừng âm thanh trước] +`spacing` must be a finite number of points `spacing` phải là một số điểm (pt) hữu hạn +`spacing` must be from 1 to 288 points `spacing` phải từ 1 đến 288 điểm (pt) +`{key}` must be true or false `{key}` phải là true hoặc false +a dictionaries folder thư mục từ điển +animation preview plays the current slide xem trước hiệu ứng động sẽ phát trang chiếu hiện tại +bottom left dưới trái +bottom right dưới phải +center giữa +dictionary từ điển +dropped file tệp được kéo thả +e.g. 1-3, 5 ví dụ: 1-3, 5 +interface fallback fonts: {} phông chữ dự phòng giao diện: {} +missing `id` thiếu `id` +missing `percent` thiếu `percent` +missing `tab` thiếu `tab` +no frame was presented (screen locked or window hidden); use ui.render không có khung hình nào được hiển thị (màn hình bị khóa hoặc cửa sổ đang ẩn); hãy dùng ui.render +no presentation is open không có bài thuyết trình nào đang mở +no slide to preview không có trang chiếu nào để xem trước +notes ghi chú +nothing to print: no presentation is open không có gì để in: không có bài thuyết trình nào đang mở +open a presentation first hãy mở một bài thuyết trình trước +open the presentation to compare first hãy mở bài thuyết trình để so sánh trước +picking a SmartArt picture needs the desktop app chọn ảnh SmartArt hiện cần ứng dụng máy tính +sRGB sRGB +sample deck northern lights bài thuyết trình mẫu bắc cực quang +size (pt) kích cỡ (pt) +tOGGLE cASE đẢO hOA tHƯỜNG +the layout it was chosen for was deleted bố cục được chọn đã bị xóa +the slide it was chosen for was deleted trang chiếu được chọn đã bị xóa +the slide master it was chosen for was deleted bản cái trang chiếu được chọn đã bị xóa +the top-left corner of the slide góc trên bên trái của trang chiếu +there was no slide to put it on không có trang chiếu nào để đặt đối tượng +top left trên trái +top right trên phải +unknown File page `{id}` trang Tệp không xác định `{id}` +unknown UI command `{id}` lệnh giao diện không xác định `{id}` +unknown command `{id}` lệnh không xác định `{id}` +video {id}: {e} video {id}: {e} +{:.1} KB {:.1} KB +{:.1} MB {:.1} MB +{b} bytes {b} byte +{c_cnt}x{r_cnt} Table Bảng {c_cnt}x{r_cnt} +{ch} Bullet {ch} Dấu đầu dòng +{count} contributors · {commits} commits {count} người đóng góp · {commits} commit +{count} slides per page {count} trang chiếu mỗi trang +{f} (Body) {f} (Nội dung) +{f} (Headings) {f} (Tiêu đề) +{kind_label} (click to change) {kind_label} (nhấp để đổi) +{label} {label} +{label} ({}) {label} ({}) +{label} must be a number {label} phải là một số +{label} needs a colour like #RRGGBB. {label} cần mã màu dạng #RRGGBB. +{name} ({n} slides) {name} ({n} trang chiếu) +{name} was not applied: the presentation it was chosen for is closed Chưa áp dụng {name}: bài thuyết trình tương ứng đã đóng +{name} was not applied: {e} Chưa áp dụng {name}: {e} +{name}: DeckCraft can't open this kind of file {name}: DeckCraft không thể mở loại tệp này +{num} Hidden {num} Đang ẩn +{n} Slides {n} trang chiếu +{n} presentations {n} bài thuyết trình +{n} slide {n} trang chiếu +{n} slides {n} trang chiếu +{off_pt} pt (centre) {off_pt} pt (tâm) +{off_pt} pt above centre {off_pt} pt phía trên tâm +{off_pt} pt below centre {off_pt} pt phía dưới tâm +{off_pt} pt left of centre {off_pt} pt bên trái tâm +{off_pt} pt right of centre {off_pt} pt bên phải tâm +{page} of {total} {page} / {total} +{path} — this file can't be found {path} — không tìm thấy tệp này +{path}: {e} {path}: {e} +{r} pt {r} pt +{r} pt glow Phát sáng {r} pt +{safe} (print).pdf {safe} (in).pdf +{t_name} ({}) {t_name} ({}) +{text} → {text} → +{when} · Resolved {when} · Đã giải quyết +{w} pt {w} pt +{} {major} {} {major} +{} {minor} {} {minor} +{} Slide Master Bản cái trang chiếu {} +{} and {} {} và {} +{} aren't installed on this computer. Text that uses them is shown in a similar font. {} chưa được cài đặt trên máy tính này. Văn bản dùng các phông chữ đó sẽ hiển thị bằng phông chữ tương tự. +{} hex Mã hex {} +{} isn't installed on this computer. Text that uses it is shown in a similar font. {} chưa được cài đặt trên máy tính này. Văn bản dùng phông chữ đó sẽ hiển thị bằng phông chữ tương tự. +{} {}: “{w}” {} {}: “{w}” +{} — {cls_label} {} — {cls_label} +{}, {} and {} {}, {} và {} +{}, {}, {} and {} more {}, {}, {} và {} mục khác +{}: not a settings file; using the defaults {}: không phải tệp cài đặt; đang dùng giá trị mặc định +{}: reading the file stopped unexpectedly {}: việc đọc tệp bị dừng đột ngột +{}: {e} {}: {e} +{}: {e}; settings not saved {}: {e}; chưa lưu cài đặt +{}: {e}; using the defaults {}: {e}; đang dùng giá trị mặc định +{}: {text} {}: {text} +ΔLOC ΔLOC +“{from}” isn't used explicitly. “{from}” không được sử dụng trực tiếp. +“{id}” is not available yet. “{id}” hiện chưa khả dụng. +• {b} • {b} +• {d} • {d} +−Bin −Bin +−LOC −LOC +■ Stop ■ Dừng +▶ Play All ▶ Phát tất cả +▶ Play From ▶ Phát từ đây +◀ Previous ◀ Trước +◉ on ◉ bật +✏ on ✏ bật +After Sau +Apply to: Áp dụng cho: +Automatic bulleted and numbered lists Tự động tạo danh sách dấu đầu dòng và đánh số +Centimeters Centimét +Click a colour in the picture to make it transparent Nhấp vào một màu trong ảnh để làm trong suốt +Columns… Cột… +Delay Độ trễ +Duration Thời lượng +Inches Inch +Inserted {} slides Đã chèn {} trang chiếu +Mark all text in the presentation as: Đánh dấu toàn bộ văn bản trong bài thuyết trình là: +Measurement units: Đơn vị đo: +Reject Từ chối +Reject All ({count}) Từ chối tất cả ({count}) +Selected text Văn bản đã chọn +Set Transparent Color: click the colour in the picture (Esc cancels) Đặt màu trong suốt: nhấp vào màu trong ảnh (Esc để hủy) +Whole presentation Toàn bộ bài thuyết trình diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4c5a7a681..fe0a23985 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; @@ -321,6 +322,13 @@ pub struct UiState { pub comment_focus: Option<(usize, usize)>, /// Quick Access Toolbar buttons, left to right. pub qat: Vec, + /// UI display language (`"auto"`, `"en"`, `"vi"`). + #[serde(default = "default_language", alias = "interfaceLanguage")] + pub language: String, +} + +fn default_language() -> String { + "auto".to_owned() } impl Default for UiState { @@ -353,6 +361,7 @@ impl Default for UiState { grayscale: false, comment_focus: None, qat: qat::defaults(), + language: default_language(), } } } @@ -396,6 +405,7 @@ impl UiState { self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); self.recent.dedup(); self.recent.truncate(RECENT_MAX); + self.language = i18n::Lang::normalize_pref(&self.language).to_owned(); self } } @@ -461,6 +471,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.find", "Find…", Some("Cmd+F"), "{}"), ("app.replace", "Replace…", Some("Cmd+H"), "{}"), ("app.links", "Community Links", None, "{}"), + ("ui.language", "Language", None, "{language: auto|en|vi}"), ]; pub struct Perf { @@ -954,7 +965,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 { - let r = if UI_COMMANDS.iter().any(|c| c.0 == id) { + let r = if UI_COMMANDS.iter().any(|c| c.0 == id) || id == "app.language" { self.run_ui(id, ¶ms) } else { self.session.execute(id, ¶ms).map_err(|e| e.to_string()) @@ -1112,7 +1123,7 @@ impl SlideApp { "deckcraft" => ("deckcraft", "deckcraft", false), "gif" => ("gif", "gif", false), other => { - self.set_status(format!("DeckCraft can't export “{other}”")); + self.set_status(tl!("DeckCraft can't export “{}”", other)); return false; } }; @@ -1125,7 +1136,7 @@ impl SlideApp { } return match self.run("file.export", params) { Ok(_) => { - self.set_status(format!("Exported {path}")); + self.set_status(tl!("Exported {}", path)); true } Err(_) => false, @@ -1163,7 +1174,7 @@ impl SlideApp { false } (Ok(_), None) => { - self.set_status("Saving a copy needs a file picker, which this window doesn't have."); + self.set_status(tl!("Saving a copy needs a file picker, which this window doesn't have.")); false } } @@ -1189,7 +1200,7 @@ impl SlideApp { match self.run("file.export", params) { Ok(_) => { self.open_url(&format!("file:///{}", path.replace('\\', "/").trim_start_matches('/'))); - self.set_status("Opened the presentation as a PDF to print"); + self.set_status(tl!("Opened the presentation as a PDF to print")); true } Err(_) => false, @@ -1376,7 +1387,7 @@ impl SlideApp { return Err("updates are handled by the browser: reload the page".into()); } "app.palette" => self.palette = Some((String::new(), 0)), - "app.preferences" => self.dialog = Some(dialogs::Dialog::new("preferences")), + "app.preferences" => self.dialog = Some(dialogs::Dialog::preferences(self)), "app.openDialog" => self.pick_and_open(), "app.insertPictureDialog" => self.pick_and_insert("picture", "insert.picture"), "app.smartArtPictureDialog" => { @@ -1400,7 +1411,7 @@ impl SlideApp { self.pick_and_insert("theme", "design.browseTheme"); } else { // The browser's asynchronous picker would open the file as a new document. - self.set_status("Browse for Themes needs the desktop app for now".to_string()); + self.set_status(tl!("Browse for Themes needs the desktop app for now").to_string()); } } "app.qat" => return qat::command(self, p), @@ -1418,7 +1429,7 @@ impl SlideApp { self.pick_and_insert("open", "review.compare"); } else { // The browser's asynchronous picker would open the file as a new document. - self.set_status("Compare needs the desktop app for now".to_string()); + self.set_status(tl!("Compare needs the desktop app for now").to_string()); } } "app.dialog" => { @@ -1429,6 +1440,24 @@ impl SlideApp { use deckcraft_engine::links::*; return Ok(json!({"discord": DISCORD, "website": WEBSITE, "apps": APPS, "appPage": APP_PAGE, "github": GITHUB})); } + "ui.language" | "app.language" => { + if let Some(lang) = p + .get("language") + .or_else(|| p.get("lang")) + .or_else(|| p.get("value")) + .and_then(Value::as_str) + { + let norm = i18n::Lang::normalize_pref(lang); + self.ui.language = norm.to_owned(); + i18n::set_current(i18n::Lang::from_pref(norm)); + } + let active = i18n::Lang::from_pref(&self.ui.language); + return Ok(json!({ + "language": self.ui.language, + "resolved": active.code(), + "nativeName": active.native_name(), + })); + } _ => return Err(format!("unknown UI command `{id}`")), } Ok(Value::Null) @@ -1551,7 +1580,7 @@ impl SlideApp { }; if timeline.is_empty() { self.anim_preview = None; - self.set_status("This slide has no animations"); + self.set_status(tl!("This slide has no animations")); return Ok(json!({"playing": false})); } let from_secs = match from { @@ -1564,7 +1593,7 @@ impl SlideApp { self.close_backstage(); let duration = timeline.total_duration(); self.anim_preview = Some(AnimPreview { uid, slide, slide_ptr: ptr, started: None, from: from_secs, elapsed: from_secs, timeline }); - self.set_status("Previewing animations"); + self.set_status(tl!("Previewing animations")); Ok(json!({"playing": true, "slide": slide, "duration": duration, "from": from_secs})) } @@ -1628,7 +1657,7 @@ impl SlideApp { /// current one (desktop only: the browser picker opens what it picks as a document). fn pick_and_reuse(&mut self, keep: bool) { let Some(pick) = self.services.pick_open.as_mut() else { - self.set_status("Reuse Slides needs the desktop app".to_string()); + self.set_status(tl!("Reuse Slides needs the desktop app").to_string()); return; }; let Some(path) = pick("open") else { return }; @@ -1674,7 +1703,7 @@ impl SlideApp { match result { Ok(()) => { self.reuse_target = None; - self.set_status(format!("Inserted {} slides", picked.len())); + self.set_status(tl!("Inserted {} slides", picked.len())); true } Err(e) => { @@ -1731,7 +1760,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(tl!("Saved {}", path)); self.ui.recent.retain(|p| *p != path); self.ui.recent.insert(0, path); } @@ -1765,7 +1794,7 @@ impl SlideApp { pub fn save(&mut self) { if self.session.active().and_then(|d| d.path.clone()).is_some() { match self.session.execute("file.save", &json!({})) { - Ok(_) => self.set_status("Saved"), + Ok(_) => self.set_status(tl!("Saved")), Err(e) => self.set_status(e.to_string()), } } else { @@ -2026,6 +2055,8 @@ impl SlideApp { log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id()); return; } + let lang = i18n::Lang::from_pref(&self.ui.language); + i18n::set_current(lang); 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 @@ -2078,6 +2109,8 @@ impl SlideApp { /// Style, fonts and zoom setup. Runs in a real UI pass, not from `run_logic`, because /// `set_fonts`/`set_visuals` only take effect for the pass that lays out the UI. fn ui_setup(&mut self, ctx: &egui::Context) -> bool { + let lang = i18n::Lang::from_pref(&self.ui.language); + i18n::set_current(lang); let scale = self.ui.ui_scale.clamp(0.5, 3.0); if (ctx.zoom_factor() - scale).abs() > 1e-3 { ctx.set_zoom_factor(scale); @@ -2111,7 +2144,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(tl!("Couldn't save AutoRecover information: {}", e)); } } if self.session.documents().iter().any(|d| d.is_dirty()) { @@ -3284,6 +3317,8 @@ 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) { + let lang = i18n::Lang::from_pref(&self.ui.language); + i18n::set_current(lang); let ctx = ui.ctx().clone(); if !Self::root_viewport_admitted(&ctx) { log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id()); diff --git a/crates/ui-egui/src/media.rs b/crates/ui-egui/src/media.rs index 956f2d9b9..f56b9c818 100644 --- a/crates/ui-egui/src/media.rs +++ b/crates/ui-egui/src/media.rs @@ -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::tf("No sound: {err}", &[("err", err.as_str())]), 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..9cc430a76 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -88,7 +88,14 @@ fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, S .filter(|c| c.enabled && !crate::UI_COMMANDS.iter().any(|u| u.1 == c.label) && !deckcraft_engine::cmd::needs_arguments(c.params)) .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) .chain(crate::UI_COMMANDS.iter().filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3)).map(|c| (c.0.to_string(), c.1.to_string(), c.2))) - .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(q) || id.to_lowercase().contains(q)) + .filter_map(|(id, en_label, sc)| { + let tr_label = crate::i18n::t(&en_label).to_string(); + let matches = q.is_empty() + || en_label.to_lowercase().contains(q) + || tr_label.to_lowercase().contains(q) + || id.to_lowercase().contains(q); + matches.then_some((id, tr_label, sc)) + }) .collect(); items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(q)); items.truncate(60); @@ -106,7 +113,7 @@ 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::tl!("Search commands…")).desired_width(f32::INFINITY).font(theme::font(15.0))); r.request_focus(); let q = query.to_lowercase(); let items = palette_items(&app.session, &q); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 5bbd0a354..b56967f53 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -36,7 +36,7 @@ impl SlideApp { pub fn open_path_in_background(&mut self, path: &str) { if let Some(o) = &self.opening { let name = o.name(); - self.set_status(format!("Still opening {name}")); + self.set_status(crate::tl!("Still opening {}", name)); return; } #[cfg(target_arch = "wasm32")] @@ -70,7 +70,7 @@ impl SlideApp { /// Stop waiting for the file being opened; the presentation stays as it is. pub fn cancel_opening(&mut self) { if let Some(o) = self.opening.take() { - self.set_status(format!("Stopped opening {}", o.name())); + self.set_status(crate::tl!("Stopped opening {}", o.name())); } } @@ -80,7 +80,7 @@ impl SlideApp { let r = match o.rx.try_recv() { Ok(r) => r, Err(TryRecvError::Empty) => return, - Err(TryRecvError::Disconnected) => Err(format!("{}: reading the file stopped unexpectedly", o.path)), + Err(TryRecvError::Disconnected) => Err(crate::tl!("{}: reading the file stopped unexpectedly", o.path)), }; let Some(o) = self.opening.take() else { return }; match r { @@ -107,13 +107,13 @@ impl SlideApp { ui.set_width(320.0); ui.horizontal(|ui| { ui.add(egui::Spinner::new().size(18.0)); - ui.label(format!("Opening {name}…")); + ui.label(crate::tl!("Opening {}…", name)); }); ui.add_space(6.0); - ui.weak(format!("Reading the file: {secs:.0} s")); + ui.weak(crate::tl!("Reading the file: {} s", format!("{secs:.0}"))); ui.add_space(6.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - cancel = ui.button("Cancel").clicked(); + cancel = ui.button(crate::tl!("Cancel")).clicked(); }); }); if cancel || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 74482f930..f4801b887 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -22,16 +22,16 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { .show(ui, |ui| { app.ui.pane_width = ui.available_width() + 20.0; let title = match pane.as_str() { - "format" => "Format Shape", - "background" => "Format Background", - "animation" => "Animation Pane", - "selection" => "Selection", - "comments" => "Comments", - "designer" => "Design Ideas", - "smartart" => "Text Pane", - "thesaurus" => "Thesaurus", - "revisions" => "Revisions", - _ => "Pane", + "format" => crate::tl!("Format Shape"), + "background" => crate::tl!("Format Background"), + "animation" => crate::tl!("Animation Pane"), + "selection" => crate::tl!("Selection"), + "comments" => crate::tl!("Comments"), + "designer" => crate::tl!("Design Ideas"), + "smartart" => crate::tl!("Text Pane"), + "thesaurus" => crate::tl!("Thesaurus"), + "revisions" => crate::tl!("Revisions"), + _ => crate::tl!("Pane"), }; ui.horizontal(|ui| { ui.label(egui::RichText::new(title).font(theme::bold(16.0))); @@ -66,7 +66,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { /// Shift+Tab demote and promote, Backspace in an empty line removes it. fn smartart(app: &mut SlideApp, ui: &mut Ui) { let Some((gid, d)) = app.session.active().and_then(deckcraft_engine::cmd::smartart::selected) else { - ui.label("Select a SmartArt graphic to edit its text."); + ui.label(crate::tl!("Select a SmartArt graphic to edit its text.")); return; }; let texts: Vec = d.nodes.iter().map(|n| n.text.clone()).collect(); @@ -78,7 +78,7 @@ fn smartart(app: &mut SlideApp, ui: &mut Ui) { let mut action: Option<(&str, Value)> = None; let mut commit: Option = None; let mut focus_row: Option = None; - ui.label(egui::RichText::new("Type your text here").color(Tokens::get(ui.ctx()).text_dim)); + ui.label(egui::RichText::new(crate::tl!("Type your text here")).color(Tokens::get(ui.ctx()).text_dim)); ui.add_space(4.0); for (i, node) in d.nodes.iter().enumerate() { let Some(draft) = drafts.get_mut(i) else { break }; @@ -117,10 +117,10 @@ fn smartart(app: &mut SlideApp, ui: &mut Ui) { } ui.add_space(6.0); ui.horizontal(|ui| { - if ui.button("+ Add").clicked() { + if ui.button(crate::tl!("+ Add")).clicked() { action = Some(("smartArt.addNode", json!({"index": app.ui.smartart_row.min(d.nodes.len().saturating_sub(1)), "position": "after"}))); } - if ui.button("Remove").clicked() && !d.nodes.is_empty() { + if ui.button(crate::tl!("Remove")).clicked() && !d.nodes.is_empty() { action = Some(("smartArt.removeNode", json!({"index": app.ui.smartart_row.min(d.nodes.len() - 1)}))); } }); @@ -191,7 +191,7 @@ fn color_button( ) { let sc = scheme(app); ui.horizontal(|ui| { - ui.label(label); + ui.label(crate::i18n::t(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::tl!("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::tl!("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::t(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::tl!("Reflection")).font(theme::bold(13.0))); ui.horizontal_wrapped(|ui| { for (l, v) in [("None", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::t(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::tl!("Glow")).font(theme::bold(13.0))); let radius = fx.glow.as_ref().map_or(0.0, |g| g.radius); fx_slider(app, ui, ("glow", sh.id.0, "r"), "Size", radius, 0.0..=150.0, " pt", "glowProps", "radius", 1.0); if let Some(g) = &fx.glow { @@ -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::tl!("Soft Edges")).font(theme::bold(13.0))); let mut s = sh.effects.as_ref().and_then(|e| e.soft_edge).unwrap_or(0.0); - if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { + if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text(crate::tl!("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::tl!("Reset Effects")).clicked() { run(app, "shape.effects", json!({"reset": true})); } } @@ -334,6 +334,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { let len = app.session.prefs.units; 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()) }; + let label = crate::i18n::t(label); 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) { @@ -345,19 +346,19 @@ 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::tl!("Size")).font(theme::bold(13.0))); fields(ui, "Height", "fh", x.h, "h", "shape.resize", false); fields(ui, "Width", "fw", x.w, "w", "shape.resize", false); fields(ui, "Rotation", "fr", x.rot, "deg", "shape.rotate", true); - if ui.checkbox(&mut lock, "Lock aspect ratio").changed() { + if ui.checkbox(&mut lock, crate::tl!("Lock aspect ratio")).changed() { ui.data_mut(|d| d.insert_temp(lock_id, lock)); } ui.add_space(6.0); - ui.label(egui::RichText::new("Position").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("Position")).font(theme::bold(13.0))); fields(ui, "Horizontal", "fx", x.x, "x", "shape.move", false); fields(ui, "Vertical", "fy", x.y, "y", "shape.move", false); ui.add_space(6.0); - ui.label(egui::RichText::new("Alt Text").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("Alt Text")).font(theme::bold(13.0))); let id = ui.scope_id().with(("alt", uid, sh.id.0)); let stored: Option<(String, String)> = ui.data_mut(|d| d.get_temp(id)); let mut alt = widgets::text_draft(stored, &sh.descr); @@ -368,35 +369,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::tl!("Text Fill")).font(theme::bold(13.0))); color_button(app, ui, "Color", None, |c| match c { Some(c) => ("format.color".into(), json!({"color": cref_param(&c)})), None => ("format.color".into(), json!({"color": "tx1"})), }); text_transparency_slider(app, ui, &sh); ui.add_space(6.0); - ui.label(egui::RichText::new("Text Box").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("Text Box")).font(theme::bold(13.0))); ui.horizontal(|ui| { - ui.label("Vertical alignment"); + ui.label(crate::tl!("Vertical alignment")); for (l, a) in [("Top", "top"), ("Middle", "middle"), ("Bottom", "bottom")] { - if ui.small_button(l).clicked() { + if ui.small_button(crate::i18n::t(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::t(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::tl!("Wrap text in shape")).changed() { run(app, "format.wrap", json!({"on": w})); } - ui.label("Margins (pt)"); + ui.label(crate::tl!("Margins (pt)")); let b = sh.text.as_ref().map(|t| t.body.clone()).unwrap_or_default(); for (l, k, v) in [ ("Left", "left", b.inset_l.unwrap_or(7.2)), @@ -404,6 +405,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { ("Top", "top", b.inset_t.unwrap_or(3.6)), ("Bottom", "bottom", b.inset_b.unwrap_or(3.6)), ] { + let l = crate::i18n::t(l); 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) { @@ -412,13 +414,13 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { }); } ui.horizontal(|ui| { - ui.label("Columns"); + ui.label(crate::tl!("Columns")); for n in 1..=3 { if ui.small_button(n.to_string()).clicked() { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } - if ui.small_button("Columns…").clicked() { + if ui.small_button(crate::tl!("Columns…")).clicked() { run(app, "format.columns", json!({})); } }); @@ -426,29 +428,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::tl!("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::tl!("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::tl!("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::tl!("Transparency"))).drag_stopped() { run(app, "picture.adjust", json!({"transparency": tr})); } - if ui.button("Reset Picture").clicked() { + if ui.button(crate::tl!("Reset Picture")).clicked() { run(app, "picture.reset", json!({})); } } _ => { // Fill & Line. - ui.label(egui::RichText::new("Fill").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("Fill")).font(theme::bold(13.0))); let kind = match &sh.fill { None => "auto", Some(Fill::None) => "none", @@ -466,7 +468,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::t(l)).clicked() { match k { "none" => run(app, "shape.fill", json!({"none": true})), "solid" => run(app, "shape.fill", json!({"color": "accent1"})), @@ -497,7 +499,7 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { if !transparency { return; } - let label = ui.label("Transparency"); + let label = ui.label(crate::tl!("Transparency")); // Seed from this shape. A shared temp defaulted to 0, so a shape that was // already 40% transparent showed as opaque, and leaving the slider wrote 0. let seed = match &sh.fill { @@ -525,7 +527,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::tl!("Rotate with shape")).changed() { let ng = deckcraft_model::style::Gradient { rotate_with_shape: rot, ..g.clone() }; run(app, "shape.fill", json!({"gradient": crate::fillui::gradient_json(&ng)})); } @@ -536,15 +538,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::tl!("Line")).font(theme::bold(13.0))); let none = matches!(sh.line.as_ref().and_then(|l| l.fill.as_ref()), Some(Fill::None)); - if ui.radio(none, "No line").clicked() { + if ui.radio(none, crate::tl!("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::tl!("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::tl!("Automatic")).clicked() { run(app, "shape.line", json!({"reset": true})); } color_button(app, ui, "Color", Some("No Outline"), |c| match c { @@ -552,14 +554,15 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { None => ("shape.line".into(), json!({"none": true})), }); ui.horizontal(|ui| { - ui.label("Width"); + let width_lbl = crate::tl!("Width"); + ui.label(width_lbl); 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) { + if let Some(v) = widgets::spinner(ui, "lw", width_lbl, 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::tl!("Dash")); for (l, d) in [("—", "solid"), ("···", "sysDot"), ("- -", "dash"), ("-·-", "dashDot"), ("— —", "lgDash")] { if ui.small_button(l).clicked() { run(app, "shape.line", json!({"dash": d})); @@ -575,11 +578,11 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { /// `shape.line` with `{key: value}`. fn line_choice(app: &mut SlideApp, ui: &mut Ui, label: &str, key: &str, current: &str, items: &[(&str, &str)]) { ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::t(label))); let shown = items.iter().find(|(_, v)| *v == current).map_or(current, |(l, _)| *l); - let combo = egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(shown).width(120.0).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(("fmt-line", key)).selected_text(crate::i18n::t(shown)).width(120.0).show_ui(ui, |ui| { for (l, v) in items { - if ui.selectable_label(*v == current, *l).clicked() { + if ui.selectable_label(*v == current, crate::i18n::t(l)).clicked() { run(app, "shape.line", json!({key: v})); } } @@ -648,7 +651,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::t(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); @@ -679,7 +682,7 @@ fn pick_fill_picture(app: &mut SlideApp) { /// when it changes. fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str, scale: f64) { ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::t(label))); let mut v = value; let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { @@ -692,13 +695,13 @@ fn pic_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &s fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft_model::style::PictureFill) { use deckcraft_model::style::PictureMode; ui.horizontal(|ui| { - ui.label("Picture source"); - if ui.button("Insert…").clicked() { + ui.label(crate::tl!("Picture source")); + if ui.button(crate::tl!("Insert…")).clicked() { pick_fill_picture(app); } }); let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); - if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + if ui.checkbox(&mut tile, crate::tl!("Tile picture as texture")).changed() { run(app, "shape.fill", json!({"pictureProps": {"tile": tile}})); } match &pf.mode { @@ -721,11 +724,11 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft let mirrors = [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")]; for (label, key, opts, cur) in [("Alignment", "align", &aligns[..], align), ("Mirror type", "mirror", &mirrors[..], flip)] { ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::t(label))); let shown = opts.iter().find(|(k, _)| k == cur).map_or(opts[0].1, |(_, l)| *l); - let combo = egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(shown).show_ui(ui, |ui| { + let combo = egui::ComboBox::from_id_salt(("picfill", id, key)).selected_text(crate::i18n::t(shown)).show_ui(ui, |ui| { for (k, l) in opts { - if ui.selectable_label(*k == cur.as_str(), *l).clicked() { + if ui.selectable_label(*k == cur.as_str(), crate::i18n::t(l)).clicked() { run(app, "shape.fill", json!({"pictureProps": {key: k}})); } } @@ -737,7 +740,7 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft PictureMode::Stretch { fill_rect } => { for (i, label) in ["Offset left", "Offset top", "Offset right", "Offset bottom"].into_iter().enumerate() { ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::t(label))); let mut v = fill_rect[i] * 100.0; let r = ui.add(egui::DragValue::new(&mut v).suffix("%").max_decimals(0)).labelled_by(field.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { @@ -750,7 +753,7 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft } } let mut rot = !pf.upright; - if ui.checkbox(&mut rot, "Rotate with shape").changed() { + if ui.checkbox(&mut rot, crate::tl!("Rotate with shape")).changed() { run(app, "shape.fill", json!({"pictureProps": {"rotateWithShape": rot}})); } } @@ -774,10 +777,10 @@ fn preset_label(name: &str) -> String { /// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`. fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); - let combo = egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::t(label))); + let combo = egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(crate::i18n::t(&preset_label(cur))).width(170.0).show_ui(ui, |ui| { for o in options { - if ui.selectable_label(*o == cur, preset_label(o)).clicked() { + if ui.selectable_label(*o == cur, crate::i18n::t(&preset_label(o))).clicked() { run(app, "shape.effects", json!({"threeD": {key: o}})); } } @@ -789,7 +792,7 @@ fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &st /// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes. fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) { ui.horizontal(|ui| { - let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let field = ui.add_sized(vec2(90.0, 20.0), egui::Label::new(crate::i18n::t(label))); let mut v = value; let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)).labelled_by(field.id); if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { @@ -804,7 +807,7 @@ fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) let s3 = raw.map(Shape3d::parse).unwrap_or_default(); let with_none: Vec<&str> = std::iter::once("none").chain(BEVELS.iter().copied()).collect(); ui.add_space(6.0); - ui.label(egui::RichText::new("3-D Format").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("3-D Format")).font(theme::bold(13.0))); for (label, key, b) in [("Top bevel", "bevelTop", &s3.top), ("Bottom bevel", "bevelBottom", &s3.bottom)] { three_d_combo(app, ui, id, label, key, b.as_ref().map_or("none", |b| b.preset.as_str()), &with_none); if let Some(b) = b { @@ -817,14 +820,14 @@ fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) three_d_combo(app, ui, id, "Material", "material", &s3.material, MATERIALS); three_d_combo(app, ui, id, "Lighting", "lighting", &s3.light, LIGHTS); ui.add_space(6.0); - ui.label(egui::RichText::new("3-D Rotation").font(theme::bold(13.0))); + ui.label(egui::RichText::new(crate::tl!("3-D Rotation")).font(theme::bold(13.0))); three_d_combo(app, ui, id, "Presets", "camera", &s3.camera, CAMERAS); let (rx, ry, rz) = s3.rot.unwrap_or((0.0, 0.0, 0.0)); three_d_drag(app, ui, "X Rotation", rx, "°", "rotX"); three_d_drag(app, ui, "Y Rotation", ry, "°", "rotY"); three_d_drag(app, ui, "Z Rotation", rz, "°", "rotZ"); three_d_drag(app, ui, "Distance from ground", s3.z, " pt", "z"); - if ui.add_enabled(raw.is_some(), egui::Button::new("Reset 3-D")).clicked() { + if ui.add_enabled(raw.is_some(), egui::Button::new(crate::tl!("Reset 3-D"))).clicked() { run(app, "shape.effects", json!({"threeD": {"reset": true}})); } } @@ -845,7 +848,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::tl!("Fill")).font(theme::bold(13.0))); for (l, k) in [ ("Solid fill", "solid"), ("Gradient fill", "gradient"), @@ -853,7 +856,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::t(l)).clicked() && kind != k { match k { "solid" => run(app, "design.background", json!({"color": "bg1"})), "gradient" => { @@ -879,7 +882,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::tl!("Color")); let r = widgets::drop_button(ui, "Color", vec2(70.0, 20.0), true); widgets::color_popup(&r).show(|ui| { if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { @@ -888,7 +891,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) { }); }); let mut tr = ((1.0 - color.alpha()) * 100.0) as f32; - if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text(crate::tl!("Transparency")).suffix("%")).drag_stopped() { run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); } } @@ -905,7 +908,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::tl!("Insert picture from file…")).clicked() && let Some(pick) = app.services.pick_open.as_mut() && let Some(path) = pick("picture") && let Some(Ok(bytes)) = app.services.read.as_ref().map(|r| r(&path)) @@ -919,13 +922,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::tl!("Hide background graphics")).changed() { run(app, "design.hideBackgroundGraphics", json!({"hide": h})); } ui.add_space(8.0); let picture = matches!(fill, Some(Fill::Picture(_))); ui.horizontal(|ui| { - if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() { + if ui.add_enabled(current.is_some() || picture, egui::Button::new(crate::tl!("Apply to All"))).clicked() { if let Some(Value::Object(mut o)) = current.clone() { o.insert("all".into(), json!(true)); run(app, "design.background", Value::Object(o)); @@ -933,7 +936,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::tl!("Reset Background")).clicked() { run(app, "design.background", json!({"reset": true})); } }); @@ -945,11 +948,11 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo use deckcraft_model::style::PictureMode; let alpha = pf.alpha.unwrap_or(1.0); let mut tr = ((1.0 - alpha) * 100.0) as f32; - if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() { + if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text(crate::tl!("Transparency")).suffix("%")).drag_stopped() { run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)})); } let mut tile = matches!(pf.mode, PictureMode::Tile { .. }); - if ui.checkbox(&mut tile, "Tile picture as texture").changed() { + if ui.checkbox(&mut tile, crate::tl!("Tile picture as texture")).changed() { run(app, "design.background", json!({"pictureProps": {"tile": tile}})); } let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return }; @@ -959,6 +962,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo ("Scale X", "bg-sx", *sx * 100.0, "%", 0.01), ("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01), ] { + let label = crate::i18n::t(label); ui.horizontal(|ui| { ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label)); if let Some(v) = widgets::spinner(ui, id, label, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) { @@ -984,8 +988,8 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo _ => "Top Left", }; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment")); - let combo = egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new(crate::tl!("Alignment"))); + let combo = egui::ComboBox::from_id_salt("bg-align").selected_text(crate::i18n::t(align_name)).width(140.0).show_ui(ui, |ui| { for (k, l) in [ ("tl", "Top Left"), ("t", "Top"), @@ -997,7 +1001,7 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo ("b", "Bottom"), ("br", "Bottom Right"), ] { - if ui.selectable_label(align == k, l).clicked() { + if ui.selectable_label(align == k, crate::i18n::t(l)).clicked() { run(app, "design.background", json!({"pictureProps": {"align": k}})); } } @@ -1011,10 +1015,10 @@ fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_mo _ => "None", }; ui.horizontal(|ui| { - let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror")); - let combo = egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| { + let field = ui.add_sized(vec2(72.0, 20.0), egui::Label::new(crate::tl!("Mirror"))); + let combo = egui::ComboBox::from_id_salt("bg-mirror").selected_text(crate::i18n::t(mirror_name)).width(140.0).show_ui(ui, |ui| { for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] { - if ui.selectable_label(flip == k, l).clicked() { + if ui.selectable_label(flip == k, crate::i18n::t(l)).clicked() { run(app, "design.background", json!({"pictureProps": {"mirror": k}})); } } @@ -1045,9 +1049,9 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { ui.horizontal(|ui| { let playing = app.anim_preview_active(); let label = match (playing, from) { - (true, _) => "■ Stop", - (false, Some(_)) => "▶ Play From", - (false, None) => "▶ Play All", + (true, _) => crate::tl!("■ Stop"), + (false, Some(_)) => crate::tl!("▶ Play From"), + (false, None) => crate::tl!("▶ Play All"), }; if ui.button(label).clicked() { let p = match (playing, from) { @@ -1059,7 +1063,7 @@ 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::tl!("Select an object on the slide and add an animation from the Animations tab.")).color(t.text_dim)); return; } let bars = animtimeline::bars(anims.len(), &effects); @@ -1071,7 +1075,12 @@ 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 shape_id = a.shape; + let name = names + .iter() + .find(|(id, _)| *id == a.shape.0) + .map(|(_, n)| n.clone()) + .unwrap_or_else(|| crate::tl!("Shape {shape_id}", shape_id = shape_id)); 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() { @@ -1112,9 +1121,10 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { ); let edge = Rect::from_min_max(pos2(bar.max.x - 3.0, bar.min.y - 3.0), pos2(bar.max.x + 4.0, bar.max.y + 3.0)); let body_resp = ui.interact(bar, ui.scope_id().with(("anim_bar", i)), Sense::drag()).on_hover_text(animtimeline::label(b)); - body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Timing bar {}", i + 1))); + let idx1 = i + 1; + body_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::tl!("Timing bar {idx1}", idx1 = idx1))); let edge_resp = ui.interact(edge, ui.scope_id().with(("anim_edge", i)), Sense::drag()); - edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, format!("Duration handle {}", i + 1))); + edge_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::tl!("Duration handle {idx1}", idx1 = idx1))); if edge_resp.hovered() || edge_resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } else if body_resp.hovered() || body_resp.dragged() { @@ -1166,23 +1176,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::t(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::tl!("Move Up")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.move", json!({"index": i, "to": i.saturating_sub(1)})); ui.close(); } - if ui.button("Move Down").clicked() { + if ui.button(crate::tl!("Move Down")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.move", json!({"index": i, "to": i + 1})); ui.close(); } - if ui.button("Remove").clicked() { + if ui.button(crate::tl!("Remove")).clicked() { ui.data_mut(|m| m.remove::(pick_id)); run(app, "animation.remove", json!({"index": i})); ui.close(); @@ -1211,38 +1221,41 @@ fn animation(app: &mut SlideApp, ui: &mut Ui) { fn revisions(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Ok(v) = app.session.execute("review.revisions", &json!({})) else { - ui.label(egui::RichText::new("Compare this presentation with a revised copy from Review › Compare.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::tl!("Compare this presentation with a revised copy from Review › Compare.")).color(t.text_dim)); return; }; let changes = v["changes"].as_array().cloned().unwrap_or_default(); - ui.label(egui::RichText::new(format!("Compared with {}", v["name"].as_str().unwrap_or("?"))).color(t.text_dim)); + let name = v["name"].as_str().unwrap_or("?"); + ui.label(egui::RichText::new(crate::tl!("Compared with {name}", name = name)).color(t.text_dim)); if v["byPosition"].as_bool() == Some(true) { - ui.label(egui::RichText::new("Not a copy of this presentation: slides are matched by position.").size(11.0).color(t.text_faint)); + ui.label(egui::RichText::new(crate::tl!("Not a copy of this presentation: slides are matched by position.")).size(11.0).color(t.text_faint)); } ui.add_space(4.0); if changes.is_empty() { - ui.label("No differences left."); - if ui.button("End Review").clicked() { + ui.label(crate::tl!("No differences left.")); + if ui.button(crate::tl!("End Review")).clicked() { run(app, "review.endCompare", json!({})); app.ui.pane = None; } return; } + let count = changes.len(); ui.horizontal(|ui| { - if ui.button(format!("Accept All ({})", changes.len())).clicked() { + if ui.button(crate::tl!("Accept All ({count})", count = count)).clicked() { run(app, "review.accept", json!({"all": true})); } - if ui.button(format!("Reject All ({})", changes.len())).clicked() { + if ui.button(crate::tl!("Reject All ({count})", count = count)).clicked() { run(app, "review.reject", json!({"all": true})); } }); ui.separator(); for (i, c) in changes.iter().enumerate() { let slide = c["slide"].as_u64().unwrap_or(0); + let slide1 = slide.saturating_add(1); let (head, col) = match c["kind"].as_str().unwrap_or("") { - "inserted" => (format!("Slide inserted after {slide}"), Color32::from_rgb(0x2E, 0xA3, 0x6F)), - "deleted" => (format!("Slide {} deleted", slide.saturating_add(1)), Color32::from_rgb(0xD8, 0x3F, 0x6B)), - _ => (format!("Slide {} changed", slide.saturating_add(1)), Color32::from_rgb(0x2E, 0x6F, 0xD8)), + "inserted" => (crate::tl!("Slide inserted after {slide}", slide = slide), Color32::from_rgb(0x2E, 0xA3, 0x6F)), + "deleted" => (crate::tl!("Slide {slide1} deleted", slide1 = slide1), Color32::from_rgb(0xD8, 0x3F, 0x6B)), + _ => (crate::tl!("Slide {slide1} changed", slide1 = slide1), Color32::from_rgb(0x2E, 0x6F, 0xD8)), }; egui::Frame::NONE.fill(t.chrome).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { ui.set_width(ui.available_width()); @@ -1254,10 +1267,10 @@ fn revisions(app: &mut SlideApp, ui: &mut Ui) { run(app, "slide.go", json!({"index": (slide as usize).min(max)})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.small_button("Reject").clicked() { + if ui.small_button(crate::tl!("Reject")).clicked() { run(app, "review.reject", json!({"index": i})); } - if ui.small_button("Accept").clicked() { + if ui.small_button(crate::tl!("Accept")).clicked() { run(app, "review.accept", json!({"index": i})); } }); @@ -1272,7 +1285,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::tl!("+ New Comment")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("comment")); } ui.separator(); @@ -1282,12 +1295,13 @@ 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::tl!("No comments.")).color(t.text_dim)); return; } let focus = app.ui.comment_focus; for (si, list) in threads { - ui.label(egui::RichText::new(format!("Slide {}", si + 1)).font(theme::bold(12.0)).color(t.text_dim)); + let n = si + 1; + ui.label(egui::RichText::new(crate::tl!("Slide {n}", n = n)).font(theme::bold(12.0)).color(t.text_dim)); ui.add_space(2.0); for (i, c) in list.iter().enumerate() { comment_card(app, ui, si, i, c, focus == Some((si, i))); @@ -1322,7 +1336,7 @@ fn comment_card(app: &mut SlideApp, ui: &mut Ui, slide: usize, index: usize, c: focus_comment(app, slide, index); } let when = c.date.replace('T', " ").trim_end_matches('Z').to_string(); - let when = if c.resolved { format!("{when} · Resolved") } else { when }; + let when = if c.resolved { crate::tl!("{when} · Resolved", when = when) } else { when }; ui.label(egui::RichText::new(when).size(10.5).color(t.text_faint)); }); }); @@ -1339,14 +1353,14 @@ 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::tl!("Reply…")).desired_width(ui.available_width() - 70.0)); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !txt.trim().is_empty() { focus_comment(app, slide, index); run(app, "comment.reply", json!({"index": index, "text": txt})); txt.clear(); } ui.data_mut(|d| d.insert_temp(id, txt)); - if ui.small_button(if c.resolved { "Reopen" } else { "Resolve" }).clicked() { + if ui.small_button(if c.resolved { crate::tl!("Reopen") } else { crate::tl!("Resolve") }).clicked() { focus_comment(app, slide, index); run(app, "comment.resolve", json!({"index": index, "resolved": !c.resolved})); } @@ -1384,7 +1398,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::tl!("Pick a design for this slide. Ideas are generated on your device.")).color(t.text_dim)); ui.add_space(6.0); let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); @@ -1447,15 +1461,15 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) { clicked }; if ideas.is_empty() { - ui.label(egui::RichText::new("Add a title with a picture or some text to get layout ideas.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::tl!("Add a title with a picture or some text to get layout ideas.")).color(t.text_dim)); } for (id, label) in &ideas { - if thumb(ui, &format!("layout:{id}"), label) { + if thumb(ui, &format!("layout:{id}"), crate::i18n::t(label)) { run(app, "design.applyIdea", json!({"id": id})); } } ui.add_space(4.0); - ui.label(egui::RichText::new("Themes (whole presentation)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::tl!("Themes (whole presentation)")).color(t.text_dim)); ui.add_space(4.0); for th in &themes { if thumb(ui, &format!("theme:{}", th.name), &th.name) { @@ -1477,7 +1491,7 @@ pub(crate) fn text_transparency_of(sh: &deckcraft_model::Shape) -> f64 { /// Text Fill › Transparency, as in PowerPoint's Format Shape › Text Options (#32). pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &deckcraft_model::Shape) { ui.horizontal(|ui| { - let label = ui.label("Transparency"); + let label = ui.label(crate::tl!("Transparency")); let id = ui.scope_id().with(("text-transparency", sh.id)); let stored = (text_transparency_of(sh) * 100.0) as f32; let mut v: f32 = ui.data_mut(|d| d.get_temp(id).unwrap_or(stored)); @@ -1686,25 +1700,26 @@ mod tests { fn thesaurus(app: &mut SlideApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let mut q = app.thesaurus_query.clone(); - let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search for a word").desired_width(f32::INFINITY)); + let edit = ui.add(egui::TextEdit::singleline(&mut q).hint_text(crate::tl!("Search for a word")).desired_width(f32::INFINITY)); if edit.changed() { app.thesaurus_query = q.clone(); } ui.add_space(6.0); let senses = deckcraft_engine::thesaurus::lookup(&q); if q.trim().is_empty() { - ui.label(egui::RichText::new("Select a word on the slide, or type one above.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::tl!("Select a word on the slide, or type one above.")).color(t.text_dim)); return; } if senses.is_empty() { - ui.label(egui::RichText::new(format!("No synonyms for \"{}\".", q.trim())).color(t.text_dim)); + let word = q.trim(); + ui.label(egui::RichText::new(crate::tl!("No synonyms for \"{word}\".", word = word)).color(t.text_dim)); return; } let editing = app.session.active().is_some_and(|d| d.selection.text.is_some()); for sense in senses { ui.label(egui::RichText::new(&sense.word).font(theme::bold(13.0))); for syn in sense.synonyms { - let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { "Insert" } else { "Look up" }); + let r = ui.add(egui::Button::new(&syn).frame(false)).on_hover_text(if editing { crate::tl!("Insert") } else { crate::tl!("Look up") }); if r.clicked() { if editing { run(app, "review.replaceWord", json!({"with": syn})); diff --git a/crates/ui-egui/src/qat.rs b/crates/ui-egui/src/qat.rs index a0b77dbd7..f46824fa0 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::t(&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,18 +126,18 @@ 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::tl!("Remove from Quick Access Toolbar")).clicked() { let _ = command(app, &json!({"remove": q.id, "params": q.params})); ui.close(); } }); } - let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, "Customize Quick Access Toolbar", true, false); + let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, crate::tl!("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::tl!("Customize Quick Access Toolbar")).strong()); for c in common() { let mut on = app.ui.qat.iter().any(|q| q.same(&c.id, &Value::Null)); - if ui.checkbox(&mut on, &c.label).changed() { + if ui.checkbox(&mut on, crate::i18n::t(&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::t(&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::tl!("Right-click any ribbon button to add it.")).weak()); + if ui.button(crate::tl!("Reset to Defaults")).clicked() { let _ = command(app, &json!({"reset": true})); ui.close(); } @@ -164,7 +165,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { pub fn ribbon_menu(app: &mut SlideApp, r: &egui::Response, icon: Icon, label: &str, id: &str, p: &Value) { 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" }; + let text = if has { crate::tl!("Remove from Quick Access Toolbar") } else { crate::tl!("Add to Quick Access Toolbar") }; if ui.button(text).clicked() { let label = label.replace('\n', " "); let key = if has { "remove" } else { "add" }; diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cedd34c4e..09ac79e7b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -79,8 +79,9 @@ 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 edited_suffix = crate::tl!(" — Edited"); let title = - app.session.active().map(|d| format!("{}{}", d.title(), if dirty { " — Edited" } else { "" })).unwrap_or_else(|| "DeckCraft".into()); + app.session.active().map(|d| format!("{}{}", d.title(), if dirty { edited_suffix } 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 +94,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(format!(" {} ", crate::tl!("Present"))).color(t.accent_text).font(theme::bold(13.0))) .fill(t.accent) .corner_radius(control_h / 2.0) .min_size(vec2(84.0, control_h)), @@ -172,33 +173,35 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let mut x = rect.min.x + 12.0; let y = rect.min.y + 2.0; { - let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0; + let file_label = crate::tl!("File"); + let w = ui.fonts_mut(|f| f.layout_no_wrap(file_label.to_string(), theme::font(13.0), t.text).size().x) + 18.0; let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0)); let resp = ui.interact(r, ui.scope_id().with("tab_file"), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "File")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, file_label)); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover); } - ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text); - if resp.on_hover_text("File (Alt+F)").clicked() { + ui.painter().text(r.center(), Align2::CENTER_CENTER, file_label, theme::font(13.0), t.text); + if resp.on_hover_text(crate::tl!("File (Alt+F)")).clicked() { app.open_backstage(crate::backstage::Page::Home); } x += w + 2.0; } let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect(); for (name, contextual) in all { + let disp = crate::i18n::t(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(disp.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, disp)); 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, disp, 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); @@ -303,6 +306,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { /// A chevron over a ribbon edge that scrolls it; `true` on the frame it is clicked. fn edge_cue(ui: &mut Ui, rect: Rect, glyph: &str, hint: &str, t: &Tokens) -> bool { + let hint = crate::i18n::t(hint); let resp = ui.interact(rect, ui.scope_id().with(glyph), Sense::click()).on_hover_text(hint); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, hint)); let ink = if resp.hovered() { t.text } else { t.text_dim }; @@ -317,7 +321,7 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va let en = enabled(app, id); let r = big_button(ui, icon, label, en); if id == "insert.dateTime" { - let name = label.replace('\n', " "); + let name = crate::i18n::t(label).replace('\n', " "); r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, &name)); } let r = r.on_hover_text(tip(id, label)); @@ -332,10 +336,10 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) { let en = enabled(app, "insert.photoAlbum"); let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false); let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…")); - main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…")); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, crate::tl!("Photo Album…"))); if let Some(a) = &arrow { - let _ = a.clone().on_hover_text("Photo Album options"); - a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options")); + let _ = a.clone().on_hover_text(crate::tl!("Photo Album options")); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, crate::tl!("Photo Album options"))); } if main.clicked() { run(app, "insert.photoAlbum", json!({})); @@ -365,6 +369,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::t(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 +402,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::tl!("Paste (⌘V)")).clicked() { run(app, "edit.paste", json!({})); } if let Some(a) = arrow { egui::Popup::menu(&a).show(|ui| { - if ui.button("Paste").clicked() { + if ui.button(crate::tl!("Paste")).clicked() { run(app, "edit.paste", json!({})); } - if ui.button("Paste and Match Formatting").clicked() { + if ui.button(crate::tl!("Paste and Match Formatting")).clicked() { run(app, "edit.pasteText", json!({})); } - if ui.button("Paste Special…").clicked() { + if ui.button(crate::tl!("Paste Special…")).clicked() { run(app, "app.pasteSpecial", json!({})); } - if ui.button("Paste Formatting").clicked() { + if ui.button(crate::tl!("Paste Formatting")).clicked() { run(app, "format.painterApply", json!({})); } }); @@ -421,7 +426,7 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) { small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({})); let on = app.session.painter.is_some(); let en = enabled(app, "format.painter"); - let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)"); + let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text(crate::tl!("Format Painter (double-click to keep it on)")); if r.double_clicked() { run(app, "format.painter", json!({"sticky": true})); } else if r.clicked() { @@ -469,7 +474,7 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) { Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }), egui::StrokeKind::Outside, ); - ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5)).truncate()); + ui.add_sized(vec2(96.0, 14.0), egui::Label::new(egui::RichText::new(crate::i18n::t(name)).size(10.5)).truncate()); resp }); if resp.inner.clicked() { @@ -487,7 +492,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::tl!("New Slide (⇧⌘N)")).clicked() { run(app, "slide.new", json!({})); } if let Some(a) = arrow { @@ -511,28 +516,28 @@ 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::tl!("Duplicate Selected Slides")).clicked() { run(app, "slide.duplicate", json!({})); } - if ui.button("Slides from Outline…").clicked() { + if ui.button(crate::tl!("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::t(label))); if r.clicked() { run(app, "app.reuseSlidesDialog", json!({"keepFormatting": keep})); } - r.on_disabled_hover_text("Choosing a presentation to reuse needs the desktop app"); + r.on_disabled_hover_text(crate::tl!("Choosing a presentation to reuse needs the desktop app")); } } /// The Section split button's menu. fn section_menu(app: &mut SlideApp, ui: &mut Ui) { - if ui.button("Add Section").clicked() { + if ui.button(crate::tl!("Add Section")).clicked() { run(app, "section.add", json!({})); } - if ui.button("Remove All Sections").clicked() { + if ui.button(crate::tl!("Remove All Sections")).clicked() { run(app, "section.removeAll", json!({})); } } @@ -561,9 +566,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::tl!("{f} (Headings)", f = f) } else if k == 1 && theme_fonts.len() == 2 { - format!("{f} (Body)") + crate::tl!("{f} (Body)", f = f) } else { f.clone() }; @@ -577,7 +582,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } ui.separator(); // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts). - if ui.button("Rescan Installed Fonts").clicked() { + if ui.button(crate::tl!("Rescan Installed Fonts")).clicked() { run(app, "font.rescan", json!({})); ui.close(); } @@ -608,7 +613,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::t(label)).clicked() { run(app, "format.spacing", json!({"pt": pt})); } } @@ -622,7 +627,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::t(label)).clicked() { run(app, "format.case", json!({"mode": mode})); } } @@ -646,7 +651,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.color", json!({"color": cref_param(&c)})); } }); - if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() { + if ui.add_enabled(en, egui::Button::new(crate::tl!("Font…")).min_size(vec2(52.0, 22.0))).on_hover_text(crate::tl!("Font")).clicked() { run(app, "format.fontDialog", json!({})); } }); @@ -736,9 +741,10 @@ fn smartart_gallery( if kinds.is_empty() { continue; } - ui.label(egui::RichText::new(title).strong()); + ui.label(egui::RichText::new(crate::i18n::t(title)).strong()); egui::Grid::new(("smartart_gallery", cat)).num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { for (i, (k, label)) in kinds.iter().enumerate() { + let disp = crate::i18n::t(label); let ctx = ui.ctx().clone(); let thumb = app.session.active().and_then(|d| app.textures.smartart(&ctx, &d.doc, slide, k, style, colors, None, 192)); let r = ui @@ -750,9 +756,9 @@ fn smartart_gallery( } None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)), }; - ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false)); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label)); - r.on_hover_text(*label) + ui.add(egui::Label::new(egui::RichText::new(disp).small()).truncate().selectable(false)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, disp)); + r.on_hover_text(disp) }) .inner; if r.hovered() { @@ -788,21 +794,21 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { ui.spacing_mut().item_spacing.x = 1.0; let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true); - if m.on_hover_text("Bullets").clicked() { + if m.on_hover_text(crate::tl!("Bullets")).clicked() { run(app, "format.bullets", json!({})); } if let Some(a) = a { egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { + if ui.button(crate::tl!("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::tl!("{ch} Bullet", ch = ch)).size(14.0)).clicked() { run(app, "format.bullets", json!({"on": true, "char": ch})); } } ui.separator(); - if ui.button("Bullets and Numbering…").clicked() { + if ui.button(crate::tl!("Bullets and Numbering…")).clicked() { let mut dlg = crate::dialogs::Dialog::new("bullets"); dlg.fields.insert("tab".into(), "bulleted".into()); app.dialog = Some(dlg); @@ -810,12 +816,12 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { }); } let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true); - if m.on_hover_text("Numbering").clicked() { + if m.on_hover_text(crate::tl!("Numbering")).clicked() { run(app, "format.numbering", json!({})); } if let Some(a) = a { egui::Popup::menu(&a).show(|ui| { - if ui.button("None").clicked() { + if ui.button(crate::tl!("None")).clicked() { run(app, "format.numbering", json!({"on": false})); } for (label, sch) in [ @@ -832,7 +838,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } } ui.separator(); - if ui.button("Bullets and Numbering…").clicked() { + if ui.button(crate::tl!("Bullets and Numbering…")).clicked() { let mut dlg = crate::dialogs::Dialog::new("bullets"); dlg.fields.insert("tab".into(), "numbered".into()); app.dialog = Some(dlg); @@ -848,17 +854,17 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { run(app, "format.lineSpacing", json!({"lines": v})); } } - if ui.button("Line Spacing Options…").clicked() { + if ui.button(crate::tl!("Line Spacing Options…")).clicked() { run(app, "format.paragraphDialog", json!({})); } - if ui.button("Tabs…").clicked() { + if ui.button(crate::tl!("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() { + for (n, label) in [(1, "1 Column"), (2, "2 Columns"), (3, "3 Columns")] { + if ui.button(crate::i18n::t(label)).clicked() { run(app, "format.columns", json!({"count": n, "spacing": 18})); } } @@ -883,7 +889,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::t(l)).clicked() { run(app, "format.direction", json!({"dir": d})); } } @@ -891,14 +897,14 @@ 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::t(l)).clicked() { run(app, "format.anchor", json!({"anchor": a})); } } }); let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false); egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None)); - if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() { + if ui.add_enabled(en, egui::Button::new(crate::tl!("Paragraph…")).min_size(vec2(88.0, 22.0))).on_hover_text(crate::tl!("Paragraph")).clicked() { run(app, "format.paragraphDialog", json!({})); } }); @@ -913,7 +919,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::t(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 +928,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::t(p.label)).clicked() { out = Some(p.name); } } @@ -933,7 +939,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::t(label)).clicked() { out = Some(name); } } @@ -1008,7 +1014,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::tl!("Shapes")); egui::Popup::menu(&r).show(|ui| { if let Some(p) = shapes_gallery(ui) { app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() }); @@ -1066,7 +1072,7 @@ 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::tl!("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() { run(app, "shape.line", json!({"width": w})); @@ -1074,7 +1080,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color } } }); - ui.menu_button("Dashes", |ui| { + ui.menu_button(crate::tl!("Dashes"), |ui| { for (l, d) in [ ("Solid", "solid"), ("Round Dot", "sysDot"), @@ -1084,13 +1090,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::t(l)).clicked() { run(app, "shape.line", json!({"dash": d})); ui.close(); } } }); - ui.menu_button("Arrows", |ui| { + ui.menu_button(crate::tl!("Arrows"), |ui| { for (l, h, tl) in [ ("None", "none", "none"), ("Arrow at end", "none", "triangle"), @@ -1099,7 +1105,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::t(l)).clicked() { run(app, "shape.line", json!({"head": h, "tail": tl})); ui.close(); } @@ -1108,7 +1114,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::tl!("Shadow"), |ui| { for (l, v) in [ ("No Shadow", "none"), ("Outer", "outer"), @@ -1117,36 +1123,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::t(l)).clicked() { run(app, "shape.effects", json!({"shadow": v})); } } }); - ui.menu_button("Reflection", |ui| { + ui.menu_button(crate::tl!("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::t(l)).clicked() { run(app, "shape.effects", json!({"reflection": v})); } } }); - ui.menu_button("Glow", |ui| { - if ui.button("No Glow").clicked() { + ui.menu_button(crate::tl!("Glow"), |ui| { + if ui.button(crate::tl!("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::tl!("{r} pt glow", r = r)).clicked() { run(app, "shape.effects", json!({"glow": r})); } } }); - ui.menu_button("Soft Edges", |ui| { + ui.menu_button(crate::tl!("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 { crate::tl!("No Soft Edges").to_string() } else { format!("{r} pt") }).clicked() { run(app, "shape.effects", json!({"softEdges": r})); } } }); - ui.menu_button("Bevel", |ui| { + ui.menu_button(crate::tl!("Bevel"), |ui| { for b in [ "none", "circle", @@ -1167,7 +1173,7 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) { } } }); - if ui.button("Format Shape…").clicked() { + if ui.button(crate::tl!("Format Shape…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"})); } } @@ -1211,7 +1217,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::tl!("Order Objects")).font(theme::bold(12.0))); for (l, id, ic) in [ ("Bring to Front", "arrange.bringToFront", Icon::BringToFront), ("Send to Back", "arrange.sendToBack", Icon::SendToBack), @@ -1221,20 +1227,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::tl!("Group Objects")).font(theme::bold(12.0))); menu_item(app, ui, Icon::Group, "Group", "arrange.group", json!({})); menu_item(app, ui, Icon::Ungroup, "Ungroup", "arrange.ungroup", json!({})); menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({})); ui.separator(); - ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0))); - ui.menu_button("Align", |ui| align_menu(app, ui)); - ui.menu_button("Rotate", |ui| { + ui.label(egui::RichText::new(crate::tl!("Position Objects")).font(theme::bold(12.0))); + ui.menu_button(crate::tl!("Align"), |ui| align_menu(app, ui)); + ui.menu_button(crate::tl!("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::tl!("Selection Pane…")).clicked() { let _ = app.run("view.pane", json!({"pane": "selection"})); ui.close(); } @@ -1332,11 +1338,11 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } ui.separator(); - if ui.button("Insert Table…").clicked() { + if ui.button(crate::tl!("Insert Table…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("table")); ui.close(); } - if ui.button("Draw Table").clicked() { + if ui.button(crate::tl!("Draw Table")).clicked() { app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); ui.close(); } @@ -1346,7 +1352,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::tl!("Screenshot (not available yet)")); }); group(ui, |ui| { let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert")); @@ -1365,7 +1371,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::t(k.label())).clicked() { run(app, "insert.chart", json!({"type": format!("{k:?}")})); } @@ -1379,13 +1385,13 @@ 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::tl!("Action Button: {k}", k = k)).clicked() { run(app, "insert.actionButton", json!({"kind": k})); } } ui.separator(); let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty()); - if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() { + if ui.add_enabled(has_sel, egui::Button::new(crate::tl!("Action Settings…"))).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("action")); } }); @@ -1399,7 +1405,8 @@ 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() { + let n = i + 1; + if ui.button(crate::tl!("WordArt style {n}", n = n)).clicked() { run(app, "insert.wordArt", json!({"style": i})); } } @@ -1423,7 +1430,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() }; + let (r_cnt, c_cnt) = hover; + let label = if r_cnt > 0 { crate::tl!("{c_cnt}x{r_cnt} Table", c_cnt = c_cnt, r_cnt = r_cnt) } else { crate::tl!("Insert Table").into() }; ui.label(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() { @@ -1456,7 +1464,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::t(cat)).small().strong()); ui.horizontal_wrapped(|ui| { for icon in icons.iter().filter(|i| i.category == cat) { icon_cell(app, ui, icon, color, hover); @@ -1469,14 +1477,17 @@ fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_eng let mut n = 0; ui.horizontal_wrapped(|ui| { for icon in icons { - if icon.name.contains(q) || icon.label.to_lowercase().contains(q) { + if icon.name.contains(q) + || icon.label.to_lowercase().contains(q) + || crate::i18n::t(icon.label).to_lowercase().contains(q) + { icon_cell(app, ui, icon, color, hover); n += 1; } } }); if n == 0 { - ui.label("No icons match."); + ui.label(crate::tl!("No icons match.")); } } @@ -1486,7 +1497,7 @@ fn icon_library(app: &mut SlideApp, ui: &mut Ui) { let (color, hover) = (t.text, t.hover); let id = ui.scope_id().with("icon-query"); let mut query = ui.data_mut(|d| d.get_temp::(id).unwrap_or_default()); - ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search icons").desired_width(ui.available_width())); + ui.add(egui::TextEdit::singleline(&mut query).hint_text(crate::tl!("Search icons")).desired_width(ui.available_width())); let q = query.trim().to_lowercase(); ui.data_mut(|d| d.insert_temp(id, query)); let icons = deckcraft_engine::icons::icons(); @@ -1581,14 +1592,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::tl!("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes")).clicked() { app.session.set_tool(ToolKind::Select); run(app, "draw.inkToShape", json!({})); } let r = big_button(ui, Icon::InkToText, "Ink to\nText", false); - let _ = r.on_hover_text("Ink to Text (coming soon)"); + let _ = r.on_hover_text(crate::tl!("Ink to Text (coming soon)")); let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false); - let _ = r.on_hover_text("Ink to Math (coming soon)"); + let _ = r.on_hover_text(crate::tl!("Ink to Math (coming soon)")); }); } @@ -1646,7 +1657,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } group(ui, |ui| { let r = big_button(ui, Icon::Colors, "Colors", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Colors")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::tl!("Colors"))); egui::Popup::menu(&r).width(240.0).show(|ui| { for cs in deckcraft_model::theme::builtin_color_schemes() { ui.horizontal(|ui| { @@ -1667,13 +1678,13 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { }); } ui.separator(); - if ui.button("Customize Colors…").clicked() { + if ui.button(crate::tl!("Customize Colors…")).clicked() { run(app, "design.colors", json!({})); ui.close(); } }); let r = big_button(ui, Icon::Fonts, "Fonts", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Fonts")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::tl!("Fonts"))); egui::Popup::menu(&r).width(220.0).show(|ui| { for fs in deckcraft_model::theme::builtin_font_schemes() { if ui.button(format!("{}\n {} / {}", fs.name, fs.major.latin, fs.minor.latin)).clicked() { @@ -1681,16 +1692,16 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator(); - if ui.button("Customize Fonts…").clicked() { + if ui.button(crate::tl!("Customize Fonts…")).clicked() { run(app, "design.fonts", json!({})); ui.close(); } }); let r = big_button(ui, Icon::ShapeEffects, "Effects", true); - r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Effects")); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::tl!("Effects"))); egui::Popup::menu(&r).width(180.0).show(|ui| { for fs in deckcraft_model::theme::builtin_format_schemes() { - if ui.button(&fs.name).clicked() { + if ui.button(crate::i18n::t(&fs.name)).clicked() { run(app, "design.effects", json!({"name": fs.name})); ui.close(); } @@ -1715,7 +1726,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::tl!("Style {i}", i = i)).clicked() { run(app, "design.background", json!({"style": i, "all": true})); ui.close(); } @@ -1724,7 +1735,7 @@ fn design(app: &mut SlideApp, ui: &mut Ui) { } } }); - if ui.button("Reset Slide Background").clicked() { + if ui.button(crate::tl!("Reset Slide Background")).clicked() { run(app, "design.background", json!({"reset": true})); } }); @@ -1732,13 +1743,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::tl!("Standard (4:3)")).clicked() { run(app, "design.slideSize", json!({"preset": "standard"})); } - if ui.button("Widescreen (16:9)").clicked() { + if ui.button(crate::tl!("Widescreen (16:9)")).clicked() { run(app, "design.slideSize", json!({"preset": "widescreen"})); } - if ui.button("Page Setup…").clicked() { + if ui.button(crate::tl!("Page Setup…")).clicked() { app.dialog = Some(crate::dialogs::Dialog::new("slideSize")); } }); @@ -1787,7 +1798,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { vec2(54.0, 56.0), sel, |p, r, i| paint_transition_tile(p, r, shown.get(i).map(|x| (x.0, x.1)).unwrap_or(("", ""))), - |i| shown.get(i).map(|x| x.1.to_string()).unwrap_or_default(), + |i| shown.get(i).map(|x| crate::i18n::t(x.1).to_string()).unwrap_or_default(), ) && let Some(x) = shown.get(i) { run(app, "transition.set", json!({"kind": x.0})); @@ -1795,7 +1806,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::t(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 +1829,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::t(&deckcraft_model::anim::transition_option_label(&kind, o))).clicked() { run(app, "transition.options", json!({"option": o})); ui.close(); } @@ -1829,39 +1840,39 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { // Sound in its own column, so the timing column keeps its three rows. rows(ui, |ui| { ui.horizontal(|ui| { - let field = ui.label("Sound:"); + let field = ui.label(crate::tl!("Sound:")); let stop = cur.as_ref().is_some_and(|t| t.stop_sound); let name = cur .as_ref() .and_then(|t| t.sound) .and_then(|m| app.session.active().and_then(|d| d.doc.media(m).map(|i| i.name.trim_end_matches(".wav").to_string()))) - .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() }); + .unwrap_or_else(|| if stop { crate::tl!("[Stop Previous Sound]").into() } else { crate::tl!("[No Sound]").into() }); let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| { let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound); - if ui.selectable_label(none, "[No Sound]").clicked() { + if ui.selectable_label(none, crate::tl!("[No Sound]")).clicked() { run(app, "transition.sound", json!({"none": true})); } - if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() { + if ui.selectable_label(stop, crate::tl!("[Stop Previous Sound]")).clicked() { run(app, "transition.sound", json!({"stopPrevious": true})); } ui.separator(); for (id, label) in deckcraft_engine::sounds::BUILTIN { - if ui.button(label).clicked() { + if ui.button(crate::i18n::t(label)).clicked() { let lp = cur.as_ref().is_some_and(|t| t.sound_loop); run(app, "transition.sound", json!({"builtin": id, "loop": lp})); } } ui.separator(); let can_pick = app.services.pick_open.is_some(); - let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…")); + let r = ui.add_enabled(can_pick, egui::Button::new(crate::tl!("Other Sound…"))); if r.clicked() { app.pick_transition_sound(); } - r.on_disabled_hover_text("Choosing a sound file needs the desktop app"); + r.on_disabled_hover_text(crate::tl!("Choosing a sound file needs the desktop app")); ui.separator(); let has = cur.as_ref().is_some_and(|t| t.sound.is_some()); let mut lp = cur.as_ref().is_some_and(|t| t.sound_loop); - if ui.add_enabled(has, egui::Checkbox::new(&mut lp, "Loop Until Next Sound")).changed() { + if ui.add_enabled(has, egui::Checkbox::new(&mut lp, crate::tl!("Loop Until Next Sound"))).changed() { run(app, "transition.sound", json!({"loop": lp})); } }); @@ -1870,25 +1881,25 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { }); rows(ui, |ui| { ui.horizontal(|ui| { - ui.label("Duration:"); + ui.label(crate::tl!("Duration:")); let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0); - if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) { + if let Some(v) = widgets::spinner(ui, "tdur", crate::tl!("Duration"), d, 0.25, "", 46.0) { run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64})); } }); 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::tl!("On Mouse Click")).changed() { run(app, "transition.timing", json!({"onClick": click})); } }); ui.horizontal(|ui| { let after = cur.as_ref().and_then(|t| t.advance_after_ms); let mut on = after.is_some(); - if ui.checkbox(&mut on, "After:").changed() { + if ui.checkbox(&mut on, crate::tl!("After:")).changed() { run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }})); } - if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { + if let Some(v) = widgets::spinner(ui, "tafter", crate::tl!("After"), after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) { run(app, "transition.timing", json!({"after": (v.max(0.0) * 1000.0).round() as u64})); } }); @@ -1898,7 +1909,7 @@ fn transitions(app: &mut SlideApp, ui: &mut Ui) { } fn option_label(o: &str) -> String { - match o { + let eng = match o { "l" => "From Left".into(), "r" => "From Right".into(), "u" => "From Top".into(), @@ -1921,7 +1932,8 @@ fn option_label(o: &str) -> String { } s } - } + }; + crate::i18n::t(&eng).to_string() } pub fn paint_transition_tile(p: &egui::Painter, r: Rect, (id, label): (&str, &str)) { @@ -1989,7 +2001,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::t(label), theme::font(10.0), t.text); } // ---------- Animations ---------- @@ -2005,7 +2017,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { let playing = app.anim_preview_active(); let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); - if resp.on_hover_text("Play this slide's animations on the canvas").clicked() { + if resp.on_hover_text(crate::tl!("Play this slide's animations on the canvas")).clicked() { run(app, "animation.preview", json!({})); } }); @@ -2042,7 +2054,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { paint_anim_tile(p, r, a.1, a.2, has_sel); } }, - |i| quick.get(i).map(|a| a.1.to_string()).unwrap_or_default(), + |i| quick.get(i).map(|a| crate::i18n::t(a.1).to_string()).unwrap_or_default(), ) && let Some(a) = quick.get(i) { run(app, "animation.set", json!({"effect": a.0, "class": a.2.xml()})); @@ -2052,7 +2064,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::t(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()); @@ -2081,7 +2093,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" }; + let label = if id == "byParagraph" { crate::tl!("By Paragraph") } else { crate::tl!("As One Object") }; if ui.selectable_label(on, label).clicked() { run(app, "animation.options", json!({"textBuild": id})); ui.close(); @@ -2093,19 +2105,13 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { let r = big_button(ui, Icon::AddAnimation, "Add\nAnimation", has_sel); egui::Popup::menu(&r).width(260.0).show(|ui| { for a in deckcraft_model::anim::ANIMATIONS.iter().filter(|a| a.2 != AnimClass::Media) { - if ui - .button(format!( - "{} — {}", - a.1, - match a.2 { - AnimClass::Entrance => "Entrance", - AnimClass::Emphasis => "Emphasis", - AnimClass::Exit => "Exit", - _ => "Motion", - } - )) - .clicked() - { + let cls_label = match a.2 { + AnimClass::Entrance => crate::tl!("Entrance"), + AnimClass::Emphasis => crate::tl!("Emphasis"), + AnimClass::Exit => crate::tl!("Exit"), + _ => crate::tl!("Motion"), + }; + if ui.button(format!("{} — {cls_label}", crate::i18n::t(a.1))).clicked() { run(app, "animation.add", json!({"effect": a.0, "class": a.2.xml()})); } } @@ -2113,13 +2119,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::tl!("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::tl!("On Click of {name}", name = name)).clicked() { run(app, "animation.timing", json!({"trigger": id})); } } @@ -2127,7 +2133,7 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { let on = app.session.anim_painter.is_some(); let en = enabled(app, "animation.painter"); let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on); - let r = r.on_hover_text("Animation Painter (double-click to keep it on)"); + let r = r.on_hover_text(crate::tl!("Animation Painter (double-click to keep it on)")); if r.double_clicked() { run(app, "animation.painter", json!({"sticky": true})); } else if r.clicked() { @@ -2141,15 +2147,16 @@ fn animations(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { rows(ui, |ui| { ui.horizontal(|ui| { - ui.label("Start:"); + ui.label(crate::tl!("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| { + let s_disp = s.map(crate::i18n::t).unwrap_or_default(); + dropdown(ui, "astart", &s_disp, 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::t(l)).clicked() { run(app, "animation.timing", json!({"start": v})); ui.close(); } @@ -2157,17 +2164,23 @@ 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::tl!("Duration:")); + if let Some(v) = widgets::spinner( + ui, + "adur", + crate::tl!("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:"); + ui.label(crate::tl!("Delay:")); if let Some(v) = - widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0) + widgets::spinner(ui, "adelay", crate::tl!("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 +2198,7 @@ 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::t(label), theme::font(10.0), if enabled { t.text } else { t.text_faint }); } // ---------- Slide Show / Record / Review / View ---------- @@ -2220,20 +2233,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::tl!("Recording narration needs audio capture (coming soon)")); }); group(ui, |ui| { rows(ui, |ui| { let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings); - if ui.checkbox(&mut use_t, "Use Timings").changed() { + if ui.checkbox(&mut use_t, crate::tl!("Use Timings")).changed() { run(app, "show.setup", json!({"useTimings": use_t})); } let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration); - if ui.checkbox(&mut narr, "Play Narrations").changed() { + if ui.checkbox(&mut narr, crate::tl!("Play Narrations")).changed() { run(app, "show.setup", json!({"noNarration": !narr})); } let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls); - if ui.checkbox(&mut mc, "Show Media Controls").changed() { + if ui.checkbox(&mut mc, crate::tl!("Show Media Controls")).changed() { run(app, "show.setup", json!({"showMediaControls": mc})); } }); @@ -2243,7 +2256,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::tl!("Camera capture is not available yet")); if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() { app.start_show(app.show_start_index(), false); if let Some(s) = app.show.as_mut() { @@ -2292,7 +2305,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::tl!("Translation needs a language model (not bundled)")); big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"})); }); group(ui, |ui| { @@ -2355,15 +2368,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::tl!("Ruler")).changed() { app.ui.ruler = r; } let mut g = app.ui.gridlines; - if ui.checkbox(&mut g, "Gridlines").changed() { + if ui.checkbox(&mut g, crate::tl!("Gridlines")).changed() { app.ui.gridlines = g; } let mut gd = app.ui.guides; - if ui.checkbox(&mut gd, "Guides").changed() { + if ui.checkbox(&mut gd, crate::tl!("Guides")).changed() { app.ui.guides = gd; } }); @@ -2377,7 +2390,7 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { }); group(ui, |ui| { let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false); - if main.on_hover_text("Zoom").clicked() { + if main.on_hover_text(crate::tl!("Zoom")).clicked() { let _ = app.run("view.zoomDialog", json!({})); } if let Some(a) = arrow { @@ -2388,7 +2401,7 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } } - if ui.button("Fit").clicked() { + if ui.button(crate::tl!("Fit")).clicked() { let _ = app.run("view.fit", json!({})); ui.close(); } @@ -2402,7 +2415,7 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { let has = app.session.active().is_some(); let on = app.doc_window.is_some(); let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0; - if resp.on_hover_text("Open another window on this presentation").clicked() { + if resp.on_hover_text(crate::tl!("Open another window on this presentation")).clicked() { let _ = app.run("view.newWindow", json!({})); } }); @@ -2423,8 +2436,8 @@ fn view(app: &mut SlideApp, ui: &mut Ui) { /// Shape Format ▸ Text Effects ▸ Transform: No Transform, Follow Path, then the other presets. fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { let current = selected_warp(app); - ui.label(egui::RichText::new("Transform").font(theme::bold(12.0))); - ui.label("Follow Path"); + ui.label(egui::RichText::new(crate::tl!("Transform")).font(theme::bold(12.0))); + ui.label(crate::tl!("Follow Path")); let mut picked = false; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); @@ -2435,7 +2448,7 @@ fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) { } } }); - ui.label("Warp"); + ui.label(crate::tl!("Warp")); egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(2.0, 2.0); @@ -2471,7 +2484,7 @@ fn warp_tile(ui: &mut Ui, preset: &str, label: &str, selected: bool) -> bool { ui.painter().rect_filled(rect, CornerRadius::same(3), t.hover); } paint_warp_icon(ui.painter(), rect.shrink(4.0), (!preset.is_empty()).then_some(preset), t.text); - resp.on_hover_text(label).clicked() + resp.on_hover_text(crate::i18n::t(label)).clicked() } /// The preset's guides, or two straight lines for No Transform. @@ -2533,12 +2546,12 @@ 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::tl!("Change Shape")); if let Some(p) = shapes_gallery(ui) { run(app, "shape.change", json!({"preset": p})); } ui.separator(); - if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() { + if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new(crate::tl!("Edit Points"))).clicked() { run(app, "shape.editPoints", json!({"on": true})); ui.close(); } @@ -2546,10 +2559,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::tl!("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::t(label)).clicked() { run(app, "shape.merge", json!({"op": op})); ui.close(); } @@ -2576,7 +2589,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::tl!("Gradient…")).clicked() { let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"})); ui.close(); } @@ -2620,7 +2633,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::tl!("Shadow")).clicked() { run(app, "format.textShadow", json!({})); ui.close(); } @@ -2703,8 +2716,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::tl!("Height:")); + ui.label(crate::tl!("Width:")); return; }; let len = app.session.prefs.units; @@ -2712,19 +2725,19 @@ 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::tl!("Height:")); + if let Some(v) = widgets::spinner(ui, "sh", crate::tl!("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::tl!("Width: ")); + if let Some(v) = widgets::spinner(ui, "sw", crate::tl!("Width"), x.w / ppu, 0.1, suffix, width) { run(app, "shape.resize", json!({"w": v.max(0.0) * ppu})); } }); }); - if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() { + if ui.add_enabled(bx.is_some(), egui::Button::new(crate::tl!("Size and Position…"))).clicked() { run(app, "shape.sizePosition", json!({})); } }); @@ -2737,7 +2750,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::t(label)).clicked() { run(app, "picture.removeBackground", json!({"tolerance": tol})); } } @@ -2745,13 +2758,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(format!("{} {:+}%", crate::tl!("Brightness"), (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(format!("{} {:+}%", crate::tl!("Contrast"), (c * 100.0) as i32)).clicked() { run(app, "picture.adjust", json!({"contrast": c})); } } @@ -2759,15 +2772,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(format!("{} {}%", crate::tl!("Saturation"), (s * 100.0) as i32)).clicked() { run(app, "picture.adjust", json!({"saturation": s})); } } ui.separator(); - ui.label("Recolor"); + ui.label(crate::tl!("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::t(l).to_string()).collect(); if let Some(i) = widgets::gallery( ui, presets.len(), @@ -2786,18 +2799,18 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); // As in PowerPoint, the pointer picks the colour in the picture itself: a colour // chosen from a grid almost never matches a photo's pixels. - if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() { + if ui.button(crate::tl!("Set Transparent Color")).on_hover_text(crate::tl!("Click a colour in the picture to make it transparent")).clicked() { crate::eyedropper::request_transparent_color(ui.ctx()); ui.close(); } }); let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect")); egui::Popup::menu(&r).show(|ui| { - if ui.button("None").clicked() { + if ui.button(crate::tl!("None")).clicked() { run(app, "picture.artisticEffect", json!({"effect": "none"})); } for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS { - if ui.button(*l).clicked() { + if ui.button(crate::i18n::t(l)).clicked() { run(app, "picture.artisticEffect", json!({"effect": e})); } } @@ -2824,7 +2837,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::t(l).to_string()).collect(); if let Some(i) = widgets::gallery( ui, STYLES.len(), @@ -2851,14 +2864,14 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator(); - ui.menu_button("Weight", |ui| { + ui.menu_button(crate::tl!("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() { run(app, "shape.line", json!({"width": w})); } } }); - ui.menu_button("Dashes", |ui| { + ui.menu_button(crate::tl!("Dashes"), |ui| { for (l, d) in [ ("Solid", "solid"), ("Round Dot", "sysDot"), @@ -2867,13 +2880,13 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) { ("Dash Dot", "dashDot"), ("Long Dash", "lgDash"), ] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::t(l)).clicked() { run(app, "shape.line", json!({"dash": d})); } } }); }); - if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() { + if ui.small_button(crate::tl!("Reset Style")).on_hover_text(crate::tl!("Remove the picture style")).clicked() { run(app, "picture.style", json!({"style": "none"})); } big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"})); @@ -2882,13 +2895,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::tl!("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::t(l)).clicked() { run(app, "picture.crop", json!({"left": v, "top": v, "right": v, "bottom": v})); } } @@ -2907,31 +2920,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::tl!("Header Row")).changed() { run(app, "table.options", json!({"headerRow": a})); } let mut d = flags.3; - if ui.checkbox(&mut d, "First Column").changed() { + if ui.checkbox(&mut d, crate::tl!("First Column")).changed() { run(app, "table.options", json!({"firstColumn": d})); } }); ui.horizontal(|ui| { let mut b = flags.1; - if ui.checkbox(&mut b, "Total Row").changed() { + if ui.checkbox(&mut b, crate::tl!("Total Row")).changed() { run(app, "table.options", json!({"totalRow": b})); } let mut e = flags.4; - if ui.checkbox(&mut e, "Last Column").changed() { + if ui.checkbox(&mut e, crate::tl!("Last Column")).changed() { run(app, "table.options", json!({"lastColumn": e})); } }); ui.horizontal(|ui| { let mut c = flags.2; - if ui.checkbox(&mut c, "Banded Rows").changed() { + if ui.checkbox(&mut c, crate::tl!("Banded Rows")).changed() { run(app, "table.options", json!({"bandedRows": c})); } let mut f = flags.5; - if ui.checkbox(&mut f, "Banded Columns").changed() { + if ui.checkbox(&mut f, crate::tl!("Banded Columns")).changed() { run(app, "table.options", json!({"bandedColumns": f})); } }); @@ -2953,7 +2966,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { paint_table_style(p, r, id, &sc); } }, - |i| quick.get(i).map(|x| x.1.clone()).unwrap_or_default(), + |i| quick.get(i).map(|x| crate::i18n::t(&x.1).to_string()).unwrap_or_default(), ) && let Some((id, _)) = quick.get(i) { run(app, "table.style", json!({"style": id})); @@ -2964,7 +2977,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::t(label)).clicked() { run(app, "table.style", json!({"style": id})); ui.close(); } @@ -3010,7 +3023,7 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { menu_item(app, ui, Icon::Borders, label, "table.borders", p); } ui.separator(); - ui.menu_button("Pen Weight", |ui| { + ui.menu_button(crate::tl!("Pen Weight"), |ui| { for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] { let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w); if ui.radio(cur, format!("{w} pt")).clicked() { @@ -3018,15 +3031,15 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { } } }); - ui.menu_button("Pen Style", |ui| { + ui.menu_button(crate::tl!("Pen Style"), |ui| { for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] { let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d; - if ui.radio(cur, l).clicked() { + if ui.radio(cur, crate::i18n::t(l)).clicked() { app.table_pen["dash"] = json!(d); } } }); - ui.menu_button("Pen Color", |ui| { + ui.menu_button(crate::tl!("Pen Color"), |ui| { let sc = scheme(app); if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) { app.table_pen["color"] = cref_param(&c); @@ -3182,14 +3195,18 @@ fn table_layout(app: &mut SlideApp, ui: &mut Ui) { toggle(app, ui, Icon::AlignRight, "Align Right", "format.alignRight", false); }); 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() { + for (l, a, tip) in [ + ("Top", "top", "Align text to the top of the cell"), + ("Center", "middle", "Align text to the center of the cell"), + ("Bottom", "bottom", "Align text to the bottom of the cell"), + ] { + if ui.small_button(crate::i18n::t(l)).on_hover_text(crate::i18n::t(tip)).clicked() { run(app, "table.cellAlign", json!({"anchor": a})); } } - ui.menu_button("Cell Margins", |ui| { + ui.menu_button(crate::tl!("Cell Margins"), |ui| { for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] { - if ui.button(l).clicked() { + if ui.button(crate::i18n::t(l)).clicked() { run(app, "table.cellMargins", json!({"preset": preset})); ui.close(); } @@ -3210,7 +3227,7 @@ fn chart_design(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); continue; }; - if ui.button(k.label()).clicked() { + if ui.button(crate::i18n::t(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:?}")})); @@ -3233,7 +3250,7 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }; let at = row.min(n.saturating_sub(1)); group(ui, |ui| { - if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() { + if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text(crate::tl!("Type and organise the diagram's text")).clicked() { run(app, "view.pane", json!({"pane": "smartart", "toggle": true})); } rows(ui, |ui| { @@ -3247,7 +3264,7 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { ("Add Shape Below", "below"), ("Add Assistant", "assistant"), ] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::t(label)).clicked() { let index = if n == 0 { Value::Null } else { json!(at) }; run(app, "smartArt.addNode", json!({"id": gid, "index": index, "position": pos})); } @@ -3263,7 +3280,7 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { }); let pictures = kind.starts_with("picture"); if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false) - .on_hover_text("Put a picture in the selected node's picture placeholder") + .on_hover_text(crate::tl!("Put a picture in the selected node's picture placeholder")) .clicked() { run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at})); @@ -3280,10 +3297,10 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true); if let Some(arrow) = arrow { widgets::split_menu(&main, &arrow, |ui| { - if ui.button("Convert to Text").clicked() { + if ui.button(crate::tl!("Convert to Text")).clicked() { run(app, "smartArt.toText", json!({"id": gid})); } - if ui.button("Convert to Shapes").clicked() { + if ui.button(crate::tl!("Convert to Shapes")).clicked() { run(app, "smartArt.toShapes", json!({"id": gid})); } }); @@ -3298,7 +3315,7 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { ui.separator(); } let on = colors == k || (colors.is_empty() && k == "colorful"); - if ui.selectable_label(on, label).clicked() { + if ui.selectable_label(on, crate::i18n::t(label)).clicked() { run(app, "smartArt.colors", json!({"id": gid, "colors": k})); } } @@ -3310,10 +3327,10 @@ fn smartart_design(app: &mut SlideApp, ui: &mut Ui) { for (k, label) in deckcraft_engine::cmd::smartart::STYLES { if k == "polished" { ui.separator(); - ui.label("3-D"); + ui.label(crate::tl!("3-D")); } let on = style == k || (style.is_empty() && k == "flat"); - if ui.selectable_label(on, label).clicked() { + if ui.selectable_label(on, crate::i18n::t(label)).clicked() { run(app, "smartArt.style", json!({"id": gid, "style": k})); } } @@ -3338,29 +3355,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::tl!("Start Automatically")).changed() { run(app, "media.options", json!({"autoplay": auto})); } let mut across = m.as_ref().is_some_and(|x| x.play_across_slides); - if ui.checkbox(&mut across, "Play Across Slides").changed() { + if ui.checkbox(&mut across, crate::tl!("Play Across Slides")).changed() { run(app, "media.options", json!({"acrossSlides": across})); } let mut lp = m.as_ref().is_some_and(|x| x.loop_play); - if ui.checkbox(&mut lp, "Loop until Stopped").changed() { + if ui.checkbox(&mut lp, crate::tl!("Loop until Stopped")).changed() { run(app, "media.options", json!({"loop": lp})); } }); rows(ui, |ui| { let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing); - if ui.checkbox(&mut hide, "Hide During Show").changed() { + if ui.checkbox(&mut hide, crate::tl!("Hide During Show")).changed() { run(app, "media.options", json!({"hide": hide})); } let mut rw = m.as_ref().is_some_and(|x| x.rewind); - if ui.checkbox(&mut rw, "Rewind after Playing").changed() { + if ui.checkbox(&mut rw, crate::tl!("Rewind after Playing")).changed() { run(app, "media.options", json!({"rewind": rw})); } let mut fs = m.as_ref().is_some_and(|x| x.full_screen); - if ui.checkbox(&mut fs, "Play Full Screen").changed() { + if ui.checkbox(&mut fs, crate::tl!("Play Full Screen")).changed() { run(app, "media.options", json!({"fullScreen": fs})); } }); @@ -3369,7 +3386,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::t(l)).clicked() { run(app, "media.options", json!({"volume": v})); } } @@ -3400,15 +3417,15 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { let (preserve, title, footers) = master_checks(app); let mut on = preserve; - if ui.checkbox(&mut on, "Preserve").changed() { + if ui.checkbox(&mut on, crate::tl!("Preserve")).changed() { run(app, "master.preserve", json!({"preserve": on})); } let mut on = title; - if ui.checkbox(&mut on, "Title").changed() { + if ui.checkbox(&mut on, crate::tl!("Title")).changed() { run(app, "master.placeholders", json!({"title": on})); } let mut on = footers; - if ui.checkbox(&mut on, "Footers").changed() { + if ui.checkbox(&mut on, crate::tl!("Footers")).changed() { run(app, "master.placeholders", json!({"footers": on})); } }); @@ -3419,7 +3436,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::t(l)).clicked() { run(app, "master.insertPlaceholder", json!({"kind": k})); } } @@ -3434,7 +3451,7 @@ fn slide_master(app: &mut SlideApp, ui: &mut Ui) { } } ui.separator(); - if ui.button("Browse for Themes…").clicked() { + if ui.button(crate::tl!("Browse for Themes…")).clicked() { run(app, "app.browseThemeDialog", json!({})); } }); @@ -3460,7 +3477,7 @@ fn notes_master(app: &mut SlideApp, ui: &mut Ui) { rows(ui, |ui| { for (label, key, k) in col { let mut on = shown(app, k); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::t(label)).changed() { run(app, "notes.placeholders", json!({ key: on })); } } @@ -3479,10 +3496,15 @@ fn notes_master(app: &mut SlideApp, ui: &mut Ui) { fn handout_master(app: &mut SlideApp, ui: &mut Ui) { group(ui, |ui| { - let r = big_button(ui, Icon::Layout, &format!("Slides Per\nPage: {}", app.ui.handout_per_page), true); + let r = big_button(ui, Icon::Layout, &format!("{}: {}", crate::tl!("Slides Per\nPage"), app.ui.handout_per_page), true); egui::Popup::menu(&r).show(|ui| { for n in [1u8, 2, 3, 4, 6, 9] { - if ui.selectable_label(app.ui.handout_per_page == n, format!("{n} Slide{}", if n == 1 { "" } else { "s" })).clicked() { + let lbl = if n == 1 { + crate::i18n::tf("1 Slide", &[]) + } else { + crate::i18n::tf("{n} Slides", &[("n", &n.to_string())]) + }; + if ui.selectable_label(app.ui.handout_per_page == n, lbl).clicked() { app.ui.handout_per_page = n; } } @@ -3501,7 +3523,7 @@ fn handout_master(app: &mut SlideApp, ui: &mut Ui) { ("Page Number", "pageNumber", PhType::SlideNum), ] { let mut on = shown(app, k); - if ui.checkbox(&mut on, label).changed() { + if ui.checkbox(&mut on, crate::i18n::t(label)).changed() { run(app, "handout.placeholders", json!({ key: on })); } } @@ -3691,10 +3713,10 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) { } let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some(); ui.horizontal(|ui| { - let label = ui.label("Smooth Corners"); + let label = ui.label(crate::tl!("Smooth Corners")); let mut pct = state.1; let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id); - let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") }; + let resp = if usable { resp } else { resp.on_disabled_hover_text(crate::tl!("For rectangles and rounded rectangles")) }; if resp.changed() { state.1 = pct; ui.data_mut(|d| d.insert_temp(key, state)); @@ -3714,7 +3736,7 @@ fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { let (mut kind, mut picked): (u8, Vec) = ui.data_mut(|d| d.get_temp(id).unwrap_or((0, vec![]))); ui.horizontal(|ui| { for (k, label) in [(0u8, "Slide Zoom"), (1, "Section Zoom"), (2, "Summary Zoom")] { - if ui.selectable_label(kind == k, label).clicked() && kind != k { + if ui.selectable_label(kind == k, crate::i18n::t(label)).clicked() && kind != k { kind = k; picked.clear(); } @@ -3734,7 +3756,11 @@ fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { .collect() }; if items.is_empty() { - ui.label(if kind == 1 { "The presentation has no sections. Add one with Home › Section." } else { "No slides." }); + ui.label(if kind == 1 { + crate::tl!("The presentation has no sections. Add one with Home › Section.") + } else { + crate::tl!("No slides.") + }); } egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (i, label) in items.iter().enumerate() { @@ -3750,7 +3776,7 @@ fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { }); ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone()))); ui.separator(); - if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() { + if ui.add_enabled(!picked.is_empty(), egui::Button::new(crate::tl!("Insert"))).clicked() { let mut v = picked.clone(); v.sort_unstable(); let p = match kind { @@ -3769,29 +3795,32 @@ fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) { fn compress_menu(app: &mut SlideApp, ui: &mut Ui) { let id = egui::Id::unique("compress_pictures_opts"); let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true))); - ui.label(egui::RichText::new("Compression options").strong()); - ui.checkbox(&mut only_selected, "Apply only to this picture"); - ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures"); + ui.label(egui::RichText::new(crate::tl!("Compression options")).strong()); + ui.checkbox(&mut only_selected, crate::tl!("Apply only to this picture")); + ui.checkbox(&mut delete_cropped, crate::tl!("Delete cropped areas of pictures")); ui.separator(); - ui.label(egui::RichText::new("Resolution").strong()); + ui.label(egui::RichText::new(crate::tl!("Resolution")).strong()); for (v, label) in [ (330, "HD (330 ppi): good quality for high-definition displays"), (220, "Print (220 ppi): excellent quality on most printers and screens"), (150, "Web (150 ppi): good for web pages and projectors"), (96, "E-mail (96 ppi): minimise document size for sharing"), ] { - ui.radio_value(&mut ppi, v, label); + ui.radio_value(&mut ppi, v, crate::i18n::t(label)); } ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped))); ui.separator(); - if ui.button("Compress").clicked() { + if ui.button(crate::tl!("Compress")).clicked() { let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected}); match app.session.execute("picture.compress", &p) { Ok(r) => { let kb = |v: &Value| v.as_u64().unwrap_or(0) / 1024; let msg = match r["pictures"].as_u64().unwrap_or(0) { - 0 => "Pictures are already at or below that resolution".to_string(), - n => format!("Compressed {n} picture(s): {} KB → {} KB", kb(&r["before"]), kb(&r["after"])), + 0 => crate::tl!("Pictures are already at or below that resolution").to_string(), + n => crate::i18n::tf( + "Compressed {n} picture(s): {before} KB → {after} KB", + &[("n", &n.to_string()), ("before", &kb(&r["before"]).to_string()), ("after", &kb(&r["after"]).to_string())], + ), }; app.set_status(msg); } diff --git a/crates/ui-egui/src/selpane.rs b/crates/ui-egui/src/selpane.rs index 1e4c54647..3039d3deb 100644 --- a/crates/ui-egui/src/selpane.rs +++ b/crates/ui-egui/src/selpane.rs @@ -107,6 +107,7 @@ fn row_id(id: u32) -> Id { /// Draw a small labelled icon button and return its interaction response. fn small_icon_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool) -> egui::Response { + let label = crate::i18n::t(label); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); resp.widget_info(|| WidgetInfo::labeled(Role::Button, enabled, label)); @@ -129,10 +130,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let has_sel = !selected.is_empty(); ui.horizontal(|ui| { - if ui.add(egui::Button::new("Show All").stroke(Stroke::new(1.0, t.border))).clicked() { + if ui.add(egui::Button::new(crate::tl!("Show All")).stroke(Stroke::new(1.0, t.border))).clicked() { run(app, "selection.showAll", json!({})); } - if ui.add(egui::Button::new("Hide All").stroke(Stroke::new(1.0, t.border))).clicked() { + if ui.add(egui::Button::new(crate::tl!("Hide All")).stroke(Stroke::new(1.0, t.border))).clicked() { run(app, "selection.hideAll", json!({})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -174,7 +175,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { let open = !app.selpane.collapsed.contains(&row.id); let cr = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); let cresp = ui.interact(cr, ui.scope_id().with(("fold", row.id)), Sense::click()); - let label = format!("{} {}", if open { "Collapse" } else { "Expand" }, row.name); + let label = format!("{} {}", if open { crate::tl!("Collapse") } else { crate::tl!("Expand") }, row.name); cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); icons::paint(&painter, cr, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, false); if cresp.clicked() { @@ -223,10 +224,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { icons::paint(&painter, eye, if row.hidden { Icon::EyeOff } else { Icon::Eye }, ink, false); icons::paint(&painter, lock, if row.locked { Icon::Lock } else { Icon::Unlock }, ink, false); let eye_resp = ui.interact(eye.expand(3.0), ui.scope_id().with(("eye", row.id)), Sense::click()); - let eye_label = format!("{} {}", if row.hidden { "Show" } else { "Hide" }, row.name); + let eye_label = format!("{} {}", if row.hidden { crate::tl!("Show") } else { crate::tl!("Hide") }, row.name); eye_resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &eye_label)); let lock_resp = ui.interact(lock.expand(3.0), ui.scope_id().with(("lock", row.id)), Sense::click()); - let lock_label = format!("{} {}", if row.locked { "Unlock" } else { "Lock" }, row.name); + let lock_label = format!("{} {}", if row.locked { crate::tl!("Unlock") } else { crate::tl!("Lock") }, row.name); lock_resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &lock_label)); if eye_resp.clicked() { run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); @@ -379,22 +380,22 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response, row: &Row) { ("Send Backward", "arrange.sendBackward"), ("Send to Back", "arrange.sendToBack"), ] { - if ui.button(label).clicked() { + if ui.button(crate::i18n::t(label)).clicked() { run(app, cmd, json!({"ids": [row.id]})); ui.close(); } } ui.separator(); - if ui.button("Rename").clicked() { + if ui.button(crate::tl!("Rename")).clicked() { app.selpane.renaming = Some((row.id, row.name.clone())); app.selpane.rename_started = false; ui.close(); } - if ui.button(if row.hidden { "Show" } else { "Hide" }).clicked() { + if ui.button(if row.hidden { crate::tl!("Show") } else { crate::tl!("Hide") }).clicked() { run(app, "shape.visible", json!({"id": row.id, "visible": row.hidden})); ui.close(); } - if ui.button(if row.locked { "Unlock" } else { "Lock" }).clicked() { + if ui.button(if row.locked { crate::tl!("Unlock") } else { crate::tl!("Lock") }).clicked() { run(app, "shape.lock", json!({"id": row.id})); ui.close(); } diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index a26583910..841f6363d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -1061,7 +1061,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::tl!("End of slide show, click to exit."), theme::font(18.0), Color32::from_gray(200), ); @@ -1301,7 +1301,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { painter.rect_filled(full, CornerRadius::ZERO, c); } if !show.goto.is_empty() { - let label = format!("Go to slide {}", show.goto); + let label = crate::tl!("Go to slide {num}", num = show.goto); let w = (28.0 + label.len() as f32 * 10.0).clamp(160.0, full.width() - 32.0); let r = Rect::from_min_size(pos2(full.min.x + 16.0, full.max.y - 96.0), vec2(w, 36.0)); painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(190)); @@ -1356,7 +1356,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { painter.text( pos2(full.center().x, full.min.y + 36.0), Align2::CENTER_CENTER, - "Click the slide to zoom in. Esc cancels.", + crate::tl!("Click the slide to zoom in. Esc cancels."), theme::font(16.0), Color32::WHITE, ); @@ -1368,7 +1368,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::tl!("Slide {current} of {total}", current = show.state.slide + 1, total = doc.slides.len()), theme::font(12.0), Color32::from_gray(200), ); @@ -1377,7 +1377,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::tl!("Close")).clicked() { keep = false; } if child.button("▶").clicked() { @@ -1406,25 +1406,27 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { keep = false; } } - if b(&mut child, if show.pen { "✏ on" } else { "✏" }).clicked() { + let pen_label = if show.pen { crate::tl!("✏ on") } else { "✏" }; + if b(&mut child, pen_label).clicked() { show.set_pen(!show.pen); } - if b(&mut child, if show.laser { "◉ on" } else { "◉" }).clicked() { + let laser_label = if show.laser { crate::tl!("◉ on") } else { "◉" }; + if b(&mut child, laser_label).clicked() { show.set_laser(!show.laser); } - if b(&mut child, "Black").clicked() { + if b(&mut child, crate::tl!("Black")).clicked() { toggle_blank(&mut show, Color32::BLACK); } - if b(&mut child, "White").clicked() { + if b(&mut child, crate::tl!("White")).clicked() { toggle_blank(&mut show, Color32::WHITE); } - if b(&mut child, "All").clicked() { + if b(&mut child, crate::tl!("All")).clicked() { show.grid = !show.grid; } - if b(&mut child, show.zoom_label()).clicked() { + if b(&mut child, crate::i18n::t(show.zoom_label())).clicked() { show.zoom_key(); } - if b(&mut child, "End").clicked() { + if b(&mut child, crate::tl!("End")).clicked() { keep = false; } ctx.request_repaint_after(std::time::Duration::from_millis(300)); @@ -1437,10 +1439,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let total = now - show.show_start; let r = Rect::from_min_size(pos2(full.min.x + 12.0, full.min.y + 12.0), vec2(220.0, 30.0)); painter.rect_filled(r, CornerRadius::same(6), Color32::from_black_alpha(160)); + let slide_time = format!("{:02}:{:04.1}", (t / 60.0) as u32, t % 60.0); + let total_time = format!("{:02}:{:02}", (total / 60.0) as u32, (total % 60.0) as u32); 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::tl!("Slide {slide_time} Total {total_time}", slide_time = slide_time, total_time = total_time), theme::font(13.0), Color32::WHITE, ); @@ -1465,7 +1469,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { for (i, secs) in show.timings.clone() { let _ = app.run("transition.timing", json!({"index": i, "after": (secs * 1000.0).round() as u64})); } - app.set_status("Slide timings recorded."); + app.set_status(crate::tl!("Slide timings recorded.")); } let last = show.state.slide; let _ = app.run("slide.go", json!({"index": last})); @@ -2134,12 +2138,12 @@ fn paint_grid(painter: &egui::Painter, ctx: &egui::Context, show: &mut Show, doc let aspect = slide_aspect(doc); let cells = see_all_cells(full, slides.len(), aspect); painter.rect_filled(full, CornerRadius::ZERO, Color32::from_gray(16)); - painter.text(pos2(full.center().x, full.min.y + 28.0), Align2::CENTER_CENTER, "All slides", theme::font(20.0), Color32::WHITE); + painter.text(pos2(full.center().x, full.min.y + 28.0), Align2::CENTER_CENTER, crate::tl!("All slides"), theme::font(20.0), Color32::WHITE); if doc.slides.len() > GRID_CAP { painter.text( pos2(full.center().x, full.min.y + 50.0), Align2::CENTER_CENTER, - format!("First {GRID_CAP} slides"), + crate::tl!("First {cap} slides", cap = GRID_CAP), theme::font(13.0), Color32::from_gray(180), ); @@ -2170,7 +2174,7 @@ fn paint_grid(painter: &egui::Painter, ctx: &egui::Context, show: &mut Show, doc egui::StrokeKind::Inside, ); let hidden = doc.slides.get(i).is_some_and(|s| s.hidden); - let label = if hidden { format!("{} Hidden", i + 1) } else { format!("{}", i + 1) }; + let label = if hidden { crate::tl!("{num} Hidden", num = i + 1) } else { format!("{}", i + 1) }; painter.text( pos2(rect.center().x, rect.max.y + 12.0), Align2::CENTER_CENTER, @@ -2230,7 +2234,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no }; 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::tl!("Presenter View — DeckCraft")).with_inner_size([1100.0, 700.0]), |ui, _| { if !keyboard(ui.ctx(), show, doc, now, true) { *keep = false; @@ -2248,36 +2252,44 @@ 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::tl!("Slide {current} of {total}", current = cur + 1, total = doc.slides.len())) + .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() { + if button(ui, crate::tl!("End Show")).clicked() { *keep = false; } - if button(ui, "Next ▶").clicked() { + if button(ui, crate::tl!("Next ▶")).clicked() { actions.push(show.state.next(doc)); } - if button(ui, "◀ Previous").clicked() { + if button(ui, crate::tl!("◀ Previous")).clicked() { actions.push(show.state.prev(doc)); } }); }); ui.horizontal(|ui| { - if button(ui, if paused { "Resume" } else { "Pause" }).clicked() { + let pause_lbl = if paused { crate::tl!("Resume") } else { crate::tl!("Pause") }; + if button(ui, pause_lbl).clicked() { show.timer.toggle(now); } - if button(ui, "Reset").clicked() { + if button(ui, crate::tl!("Reset")).clicked() { show.timer.reset(now); } - if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() { + let black_lbl = if show.blank == Some(Color32::BLACK) { crate::tl!("Black on") } else { crate::tl!("Black") }; + if button(ui, black_lbl).clicked() { toggle_blank(show, Color32::BLACK); } - if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { + let white_lbl = if show.blank == Some(Color32::WHITE) { crate::tl!("White on") } else { crate::tl!("White") }; + if button(ui, white_lbl).clicked() { toggle_blank(show, Color32::WHITE); } - if button(ui, if show.presenter_grid { "All slides on" } else { "All slides" }).clicked() { + let grid_lbl = if show.presenter_grid { crate::tl!("All slides on") } else { crate::tl!("All slides") }; + if button(ui, grid_lbl).clicked() { show.presenter_grid = !show.presenter_grid; } - if button(ui, show.zoom_label()).clicked() { + if button(ui, crate::i18n::t(show.zoom_label())).clicked() { show.zoom_key(); } }); @@ -2327,20 +2339,21 @@ 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::tl!("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::tl!("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)); let mut head = ui.new_child(egui::UiBuilder::new().max_rect(notes_head)); head.horizontal(|ui| { - ui.label(egui::RichText::new("Notes").size(13.0).color(Color32::from_gray(170))); + ui.label(egui::RichText::new(crate::tl!("Notes")).size(13.0).color(Color32::from_gray(170))); if ui.button("A-").clicked() { show.nudge_notes(-NOTES_STEP); } @@ -2373,7 +2386,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32) -> egui::Response { let color = Color32::from_gray(230); if notes.text().is_empty() { - ui.label(egui::RichText::new("No notes.").size(px).color(color)) + ui.label(egui::RichText::new(crate::tl!("No notes.")).size(px).color(color)) } else { crate::notes::view(ui, notes, &crate::notes::Look { size: px, color: Some(color) }) } diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 210a802c4..fd2855e2c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -91,7 +91,7 @@ fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { painter.rect_filled(r, CornerRadius::same(4), bg); let chev = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(24.0, 24.0)); let cresp = ui.interact(chev, ui.scope_id().with(("sorter-fold", h.uid, h.index)), Sense::click()); - let fold = format!("{} {}", if open { "Collapse" } else { "Expand" }, h.name); + let fold = format!("{} {}", if open { crate::tl!("Collapse") } else { crate::tl!("Expand") }, h.name); cresp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &fold)); icons::paint(&painter, chev.shrink(4.0), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_dim, h.first.is_none()); let renaming = app.sorter.renaming.clone().filter(|(uid, index, _)| *uid == h.uid && *index == h.index).map(|(_, _, text)| text); @@ -147,31 +147,31 @@ fn section_header(app: &mut SlideApp, ui: &mut Ui, h: &Header<'_>) -> Rect { let index = h.index; let name = h.name.to_string(); resp.context_menu(|ui| { - if ui.button("Rename Section…").clicked() { + if ui.button(crate::tl!("Rename Section…")).clicked() { let mut d = crate::dialogs::Dialog::new("renameSection"); d.params = json!({"index": index, "name": name}); app.dialog = Some(d); ui.close(); } - if ui.button("Add Section").clicked() { + if ui.button(crate::tl!("Add Section")).clicked() { if let Some(at) = app.session.active().map(|st| st.selection.slide) { let _ = app.run("section.add", json!({"at": at})); } ui.close(); } - if ui.button("Remove Section").clicked() { + if ui.button(crate::tl!("Remove Section")).clicked() { let _ = app.run("section.remove", json!({"index": index})); ui.close(); } - if ui.button("Remove Section & Slides").clicked() { + if ui.button(crate::tl!("Remove Section & Slides")).clicked() { let _ = app.run("section.remove", json!({"index": index, "slides": true})); ui.close(); } - if ui.button("Move Section Up").clicked() { + if ui.button(crate::tl!("Move Section Up")).clicked() { let _ = app.run("section.move", json!({"index": index, "to": index.saturating_sub(1)})); ui.close(); } - if ui.button("Move Section Down").clicked() { + if ui.button(crate::tl!("Move Section Down")).clicked() { let _ = app.run("section.move", json!({"index": index, "to": index + 1})); ui.close(); } @@ -201,7 +201,7 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32, for c in 0..cols { let Some(&i) = g.indices.get(row * cols + c) else { break }; let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag()); - let label = format!("Slide {}", i + 1); + let label = crate::tl!("Slide {}", i + 1); resp.widget_info(|| WidgetInfo::labeled(Role::Button, true, &label)); let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp); rects.push((i, cell)); @@ -281,7 +281,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) { ui.add_space(12.0); ui.horizontal(|ui| { ui.add_space(24.0); - if ui.add(egui::Button::new("Add Section").stroke(Stroke::new(1.0, t.border))).clicked() { + if ui.add(egui::Button::new(crate::tl!("Add Section")).stroke(Stroke::new(1.0, t.border))).clicked() { let _ = app.run("section.add", json!({"at": cur})); } }); @@ -422,7 +422,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) { let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) }; let width = notes_rect.width(); let r = crate::notes::editor(&mut child, &mut draft, &look, |e| { - e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE) + e.id(edit_id).hint_text(crate::tl!("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..00ed7f1cd 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -17,8 +17,8 @@ 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::tl!("Slide {} of {}", (st.selection.slide + 1).min(n.max(1)), n), + _ => crate::tl!("Slide Master").to_string(), }; let p = ui.painter(); // Left to right: slide counter, language, status message. Each starts at its usual column, or @@ -29,14 +29,16 @@ 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 lang_str = crate::squiggles::display_lang_name(ui.ctx(), &lang); + let name = crate::i18n::t(&lang_str).to_string() + if no_proof { crate::tl!(" (not checked)") } else { "" }; let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0); let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y)); let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim); let hit = lr.expand(3.0).intersect(lclip); if hit.is_positive() { - let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language"); - resp.widget_info(|| named_with_value("Language", &name)); + let lang_tip = crate::tl!("Language"); + let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text(lang_tip); + resp.widget_info(|| named_with_value(lang_tip, &name)); if resp.clicked() { app.dialog = Some(crate::dialogs::Dialog::new("language")); } @@ -117,6 +119,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) { /// Draw a small status icon with a tooltip and return its interaction response. fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let tip = crate::i18n::t(tip); let resp = icon_button(ui, icon, on); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); resp.on_hover_text(tip) @@ -124,6 +127,7 @@ fn small_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { /// A view button: screen readers hear its name and whether its view is the one shown. fn view_icon(ui: &mut Ui, icon: Icon, tip: &str, on: bool) -> egui::Response { + let tip = crate::i18n::t(tip); let resp = icon_button(ui, icon, on); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip)); resp.on_hover_text(tip) @@ -155,6 +159,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::t(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()); @@ -215,7 +220,7 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { .auto_shrink([false, false]) .show(ui, |ui| { crate::notes::editor(ui, &mut draft, &look, |e| { - e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) + e.id(id).hint_text(crate::tl!("Click to add notes")).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2) }) }) .inner; @@ -266,12 +271,12 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { .collect() }; if let Some(from) = w.delete_from { - if ui.button(egui::RichText::new("Delete Repeated Word").strong()).clicked() { + if ui.button(egui::RichText::new(crate::tl!("Delete Repeated Word")).strong()).clicked() { changed = Some(splice(ls + from, "")); ui.close(); } } else if w.suggestions.is_empty() { - ui.label(egui::RichText::new("(no spelling suggestions)").weak()); + ui.label(egui::RichText::new(crate::tl!("(no spelling suggestions)")).weak()); } for to in &w.suggestions { if ui.button(egui::RichText::new(to).strong()).clicked() { @@ -279,11 +284,11 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) { ui.close(); } } - if ui.button("Ignore All").clicked() { + if ui.button(crate::tl!("Ignore All")).clicked() { app.squiggles.ignore(&w.word); ui.close(); } - if w.delete_from.is_none() && ui.button("Add to Dictionary").clicked() { + if w.delete_from.is_none() && ui.button(crate::tl!("Add to Dictionary")).clicked() { if let Err(e) = app.session.execute("review.addToDictionary", &json!({"word": w.word})) { app.set_status(e.to_string()); } diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 25be3e8c2..632239e20 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::tl!("Rename Section…")).clicked() { let mut d = crate::dialogs::Dialog::new("renameSection"); d.params = json!({"index": si, "name": sec.name}); app.dialog = Some(d); ui.close(); } - if ui.button("Remove Section").clicked() { + if ui.button(crate::tl!("Remove Section")).clicked() { let _ = app.run("section.remove", json!({"index": si})); ui.close(); } - if ui.button("Remove Section & Slides").clicked() { + if ui.button(crate::tl!("Remove Section & Slides")).clicked() { let _ = app.run("section.remove", json!({"index": si, "slides": true})); ui.close(); } - if ui.button("Move Section Up").clicked() { + if ui.button(crate::tl!("Move Section Up")).clicked() { let _ = app.run("section.move", json!({"index": si, "to": si.saturating_sub(1)})); ui.close(); } - if ui.button("Move Section Down").clicked() { + if ui.button(crate::tl!("Move Section Down")).clicked() { let _ = app.run("section.move", json!({"index": si, "to": si + 1})); ui.close(); } @@ -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::tl!("+ New Slide"), theme::font(12.0), t.text_faint); } if resp.clicked() { let _ = app.run("slide.new", json!({})); @@ -229,52 +229,52 @@ 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::t(l)).clicked() { let _ = app.run(id, p); ui.close(); } } ui.separator(); - if ui.button("New Slide").clicked() { + if ui.button(crate::tl!("New Slide")).clicked() { let _ = app.run("slide.new", json!({})); ui.close(); } - if ui.button("Duplicate Slide").clicked() { + if ui.button(crate::tl!("Duplicate Slide")).clicked() { let _ = app.run("slide.duplicate", json!({})); ui.close(); } - if ui.button("Delete Slide").clicked() { + if ui.button(crate::tl!("Delete Slide")).clicked() { let _ = app.run("slide.delete", json!({})); ui.close(); } ui.separator(); - if ui.button("Add Section").clicked() { + if ui.button(crate::tl!("Add Section")).clicked() { let _ = app.run("section.add", json!({"at": i})); ui.close(); } - ui.menu_button("Layout", |ui| { + ui.menu_button(crate::tl!("Layout"), |ui| { let layouts: Vec<(String, u32)> = app .session .active() .and_then(|d| d.doc.masters.first().map(|m| m.layouts.iter().map(|l| (l.name.clone(), l.id.0)).collect())) .unwrap_or_default(); for (n, id) in layouts { - if ui.button(n).clicked() { + if ui.button(crate::i18n::t(&n)).clicked() { let _ = app.run("slide.layout", json!({"layout": id})); ui.close(); } } }); - if ui.button("Reset Slide").clicked() { + if ui.button(crate::tl!("Reset Slide")).clicked() { let _ = app.run("slide.reset", json!({})); ui.close(); } - if ui.button("Format Background…").clicked() { + if ui.button(crate::tl!("Format Background…")).clicked() { let _ = app.run("view.pane", json!({"pane": "background"})); ui.close(); } let hidden = app.session.active().and_then(|d| d.doc.slides.get(i).map(|s| s.hidden)).unwrap_or(false); - if ui.button(if hidden { "Show Slide" } else { "Hide Slide" }).clicked() { + if ui.button(if hidden { crate::tl!("Show Slide") } else { crate::tl!("Hide Slide") }).clicked() { let _ = app.run("slide.hide", json!({"index": i})); ui.close(); } @@ -327,8 +327,8 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) { egui::StrokeKind::Outside, ); let name = match li { - Some(l) => m.layouts.get(l).map(|x| x.name.clone()).unwrap_or_default(), - None => format!("{} Slide Master", m.name), + Some(l) => m.layouts.get(l).map(|x| crate::i18n::t(&x.name).to_string()).unwrap_or_default(), + None => crate::tl!("{} Slide Master", m.name), }; let resp = resp.on_hover_text(name); if resp.clicked() { diff --git a/crates/ui-egui/src/toast.rs b/crates/ui-egui/src/toast.rs index 896f9049a..bae9be356 100644 --- a/crates/ui-egui/src/toast.rs +++ b/crates/ui-egui/src/toast.rs @@ -20,12 +20,15 @@ pub fn missing_fonts_message(fonts: &[String]) -> Option { let names = match fonts { [] => return None, [a] => a.clone(), - [a, b] => format!("{a} and {b}"), - [a, b, c] => format!("{a}, {b} and {c}"), - [a, b, c, rest @ ..] => format!("{a}, {b}, {c} and {} more", rest.len()), + [a, b] => crate::tl!("{} and {}", a, b), + [a, b, c] => crate::tl!("{}, {} and {}", a, b, c), + [a, b, c, rest @ ..] => crate::tl!("{}, {}, {} and {} more", a, b, c, rest.len()), }; - let (it, them) = if fonts.len() == 1 { ("isn't", "it") } else { ("aren't", "them") }; - Some(format!("{names} {it} installed on this computer. Text that uses {them} is shown in a similar font.")) + if fonts.len() == 1 { + Some(crate::tl!("{} isn't installed on this computer. Text that uses it is shown in a similar font.", names)) + } else { + Some(crate::tl!("{} aren't installed on this computer. Text that uses them is shown in a similar font.", names)) + } } /// Toast the active presentation's missing fonts, once per opened document. @@ -61,12 +64,12 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) { ui.horizontal(|ui| { ui.add(egui::Label::new(egui::RichText::new(&text).font(theme::font(13.0)).color(t.text)).wrap()); let (r, resp) = ui.allocate_exact_size(vec2(20.0, 20.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Close")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, crate::tl!("Close"))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(4), t.hover); } icons::paint(ui.painter(), r.shrink(3.0), Icon::Close, t.text_dim, false); - if resp.on_hover_text("Close").clicked() { + if resp.on_hover_text(crate::tl!("Close")).clicked() { close = true; } }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5bfbf041e..b864b29ee 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::t(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 = if label.is_empty() { "" } else { crate::i18n::t(label) }; + let name = if name.is_empty() { "" } else { crate::i18n::t(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 }; @@ -244,6 +247,7 @@ fn split_popup_id(main: &Response) -> egui::Id { /// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the /// [`small_button`] items around it. pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response { + let label = crate::i18n::t(label); let t = Tokens::get(ui.ctx()); let font = theme::font(12.0); let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0; @@ -262,6 +266,7 @@ pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response /// A square icon toggle (B, I, U, alignment…). pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response { + let tip = crate::i18n::t(tip); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() }); let painter = ui.painter_at(rect); @@ -276,6 +281,7 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) /// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…). pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: bool) -> (Response, Response) { + let tip = crate::i18n::t(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() }); @@ -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::tl!("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::t(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::tl!("Standard Colors")).font(theme::bold(12.0))); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; for (c, name) in deckcraft_color::STANDARD_COLORS { @@ -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::t(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::tl!("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)) @@ -475,7 +481,7 @@ pub fn color_grid(ui: &mut Ui, scheme: &ColorScheme, none_label: Option<&str>) - } }); ui.data_mut(|d| d.insert_temp(id, hex)); - if ui.button("Eyedropper").on_hover_text("Click the slide to take the colour under the pointer").clicked() { + if ui.button(crate::tl!("Eyedropper")).on_hover_text(crate::tl!("Click the slide to take the colour under the pointer")).clicked() { crate::eyedropper::request(ui.ctx()); ui.close(); } @@ -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 tip_s = tip(i); + if resp.on_hover_text(crate::i18n::t(&tip_s)).clicked() { clicked = Some(i); } } @@ -573,6 +580,7 @@ pub fn text_draft(stored: Option<(String, String)>, current: &str) -> String { /// Section header inside panes. pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { + let title = crate::i18n::t(title); let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); let p = ui.painter(); @@ -589,6 +597,7 @@ pub fn section(ui: &mut Ui, title: &str, open: bool) -> bool { /// A button with an optional label and a painted dropdown chevron (the UI font has no ▾). pub fn drop_button(ui: &mut Ui, label: &str, size: egui::Vec2, enabled: bool) -> egui::Response { + let label = if label.is_empty() { "" } else { crate::i18n::t(label) }; let t = Tokens::get(ui.ctx()); let r = ui .add_enabled(enabled, egui::Button::new(if label.is_empty() { String::new() } else { format!("{label} ") }).min_size(size))