diff --git a/crates/update/src/dialog.rs b/crates/update/src/dialog.rs index 66dd956c..dcdc6e85 100644 --- a/crates/update/src/dialog.rs +++ b/crates/update/src/dialog.rs @@ -4,6 +4,7 @@ use egui::{Align, Align2, Color32, Layout, RichText, Vec2}; use crate::app::App; use crate::release::Release; +use crate::translate::{Translate, button, english}; use crate::updater::Status; /// What the user chose in the startup update prompt. @@ -29,13 +30,31 @@ pub struct StartupPrompt { pub open: bool, pub status_message: Option, pub is_installing: bool, + /// Puts the card's words in the interface language ([`crate::translate`]); English by default. + pub translate: Translate, } impl StartupPrompt { /// Creates a new prompt dialog state for the given app and newly discovered release. pub fn new(app: &'static App, release: Release, running_version: impl Into) -> Self { let is_self_updating = crate::self_update::is_self_updating(app); - Self { app, release, running_version: running_version.into(), is_self_updating, open: true, status_message: None, is_installing: false } + Self { + app, + release, + running_version: running_version.into(), + is_self_updating, + open: true, + status_message: None, + is_installing: false, + translate: english, + } + } + + /// The prompt with its words in the interface language ([`crate::translate`]). + #[must_use] + pub fn with_translator(mut self, translate: Translate) -> Self { + self.translate = translate; + self } /// Renders the modal window on the given egui context. @@ -54,6 +73,8 @@ impl StartupPrompt { let pal = Palette::new(ctx, self.app); let Palette { is_dark, card_bg, border_color, text_primary, text_muted, fill, on_fill } = pal; + let t = self.translate; + let version = self.release.version_label(); egui::Area::new(egui::Id::unique("craft_update.startup_prompt")) .anchor(Align2::CENTER_CENTER, Vec2::ZERO) @@ -67,8 +88,8 @@ impl StartupPrompt { .show(ui, |ui| { ui.set_width(400.0); style_card(ui, &pal); - let version_line = format!("v{} to {}", self.running_version, self.release.version_label()); - if card_header(ui, self.app, &pal, true, &version_line) { + let version_line = t("v{from} to {to}", &[("from", &self.running_version), ("to", &version)]); + if card_header(ui, self.app, &pal, t, true, &version_line) { response = StartupPromptResponse::Later; } @@ -92,11 +113,13 @@ impl StartupPrompt { if self.is_installing { ui.horizontal(|ui| { ui.spinner(); - ui.label(RichText::new("Downloading and installing update…").size(12.0).color(text_primary)); + ui.label(RichText::new(t("Downloading and installing update…", &[])).size(12.0).color(text_primary)); }); } else { - let desc = - format!("{} {} is available with the latest features and bug fixes.", self.app.name, self.release.version_label()); + let desc = t( + "{app} {version} is available with the latest features and bug fixes.", + &[("app", self.app.name), ("version", &version)], + ); ui.label(RichText::new(desc).size(12.5).color(text_muted)); } @@ -110,9 +133,9 @@ impl StartupPrompt { ui.with_layout(Layout::left_to_right(Align::Center), |ui| { ui.set_min_height(26.0); // Body text, so it reads on any card; the underline carries the accent. - let link = - ui.add(egui::Label::new(RichText::new("What's new").size(11.5).color(text_primary)).sense(egui::Sense::click())); - link.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, true, "What's new")); + let whats_new = t("What's new", &[]); + let link = ui.add(egui::Label::new(RichText::new(&whats_new).size(11.5).color(text_primary)).sense(egui::Sense::click())); + link.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Link, true, &whats_new)); let y = link.rect.bottom() - 0.5; ui.painter() .line_segment([egui::pos2(link.rect.left(), y), egui::pos2(link.rect.right(), y)], egui::Stroke::new(1.0, fill)); @@ -127,7 +150,7 @@ impl StartupPrompt { ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if self.is_installing { - let dismiss_btn = egui::Button::new(RichText::new("DISMISS").size(10.5).monospace().color(text_muted)) + let dismiss_btn = egui::Button::new(RichText::new(button(t, "Dismiss")).size(10.5).monospace().color(text_muted)) .corner_radius(egui::CornerRadius::ZERO) .min_size(Vec2::new(72.0, 26.0)); if ui.add(dismiss_btn).clicked() { @@ -135,25 +158,27 @@ impl StartupPrompt { } } else { if self.is_self_updating { - let update_btn = - egui::Button::new(RichText::new("UPDATE AND RESTART").size(10.5).strong().monospace().color(on_fill)) - .fill(fill) - .corner_radius(egui::CornerRadius::ZERO) - .min_size(Vec2::new(140.0, 26.0)); + let update_btn = egui::Button::new( + RichText::new(button(t, "Update and Restart")).size(10.5).strong().monospace().color(on_fill), + ) + .fill(fill) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(140.0, 26.0)); if ui.add(update_btn).clicked() { response = StartupPromptResponse::UpdateAndRestart; } } else { - let dl_btn = egui::Button::new(RichText::new("DOWNLOAD").size(10.5).strong().monospace().color(on_fill)) - .fill(fill) - .corner_radius(egui::CornerRadius::ZERO) - .min_size(Vec2::new(90.0, 26.0)); + let dl_btn = + egui::Button::new(RichText::new(button(t, "Download")).size(10.5).strong().monospace().color(on_fill)) + .fill(fill) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(90.0, 26.0)); if ui.add(dl_btn).clicked() { response = StartupPromptResponse::Download; } } - let later_btn = egui::Button::new(RichText::new("LATER").size(10.5).monospace().color(text_primary)) + let later_btn = egui::Button::new(RichText::new(button(t, "Later")).size(10.5).monospace().color(text_primary)) .corner_radius(egui::CornerRadius::ZERO) .min_size(Vec2::new(65.0, 26.0)); if ui.add(later_btn).clicked() { @@ -235,8 +260,8 @@ fn style_card(ui: &mut egui::Ui, pal: &Palette) { } /// Header row: app icon (or its initial in a badge), name, developer, `second_line` and a close -/// button. `update_pill` adds the "UPDATE" chip. True when the close button was clicked. -fn card_header(ui: &mut egui::Ui, app: &App, pal: &Palette, update_pill: bool, second_line: &str) -> bool { +/// button, worded by `t`. `update_pill` adds the "UPDATE" chip. True when the close button was clicked. +fn card_header(ui: &mut egui::Ui, app: &App, pal: &Palette, t: Translate, update_pill: bool, second_line: &str) -> bool { let Palette { text_primary, text_muted, fill, on_fill, .. } = *pal; let mut closed = false; ui.horizontal(|ui| { @@ -256,12 +281,12 @@ fn card_header(ui: &mut egui::Ui, app: &App, pal: &Palette, update_pill: bool, s ui.spacing_mut().item_spacing.y = 2.0; ui.horizontal(|ui| { ui.label(RichText::new(app.name).size(15.0).strong().color(text_primary)); - ui.label(RichText::new(format!("by {}", app.developer)).size(11.5).color(text_muted)); + ui.label(RichText::new(t("by {developer}", &[("developer", app.developer)])).size(11.5).color(text_muted)); if update_pill { // A chip, not accent-coloured text: small accent text misses WCAG's 4.5:1 for // several apps (PdfCraft's red on the dark card, amber on the light one). egui::Frame::new().fill(fill).inner_margin(egui::Margin::symmetric(4, 1)).show(ui, |ui| { - ui.label(RichText::new("UPDATE").size(10.0).monospace().color(on_fill)); + ui.label(RichText::new(button(t, "Update")).size(10.0).monospace().color(on_fill)); }); } }); @@ -273,7 +298,7 @@ fn card_header(ui: &mut egui::Ui, app: &App, pal: &Palette, update_pill: bool, s ui.with_layout(Layout::right_to_left(Align::Min), |ui| { // Two strokes, not a "✕" glyph: none of the bundled faces has U+2715. let (rect, close) = ui.allocate_exact_size(Vec2::splat(18.0), egui::Sense::click()); - close.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Close")); + close.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, t("Close", &[]))); let color = if close.hovered() { text_primary } else { text_muted }; let (center, reach) = (rect.center(), 4.5); let stroke = egui::Stroke::new(1.5, color); @@ -310,12 +335,20 @@ pub enum CheckResultResponse { pub struct CheckResultDialog { app: &'static App, running_version: &'static str, + translate: Translate, } impl CheckResultDialog { /// A dialog for `app`, which is running `running_version`. pub fn new(app: &'static App, running_version: &'static str) -> Self { - Self { app, running_version } + Self { app, running_version, translate: english } + } + + /// The dialog with its words in the interface language ([`crate::translate`]). + #[must_use] + pub fn with_translator(mut self, translate: Translate) -> Self { + self.translate = translate; + self } /// Draws the modal card for `status` and returns what the user chose. [`Status::Idle`] draws @@ -334,6 +367,7 @@ impl CheckResultDialog { let pal = Palette::new(ctx, self.app); let Palette { card_bg, border_color, text_primary, text_muted, fill, on_fill, .. } = pal; + let t = self.translate; egui::Area::new(egui::Id::unique("craft_update.check_result")).anchor(Align2::CENTER_CENTER, Vec2::ZERO).order(egui::Order::Foreground).show( ctx, |ui| { @@ -345,7 +379,7 @@ impl CheckResultDialog { .show(ui, |ui| { ui.set_width(400.0); style_card(ui, &pal); - if card_header(ui, self.app, &pal, false, &format!("v{}", self.running_version)) { + if card_header(ui, self.app, &pal, t, false, &format!("v{}", self.running_version)) { response = CheckResultResponse::Close; } ui.add_space(8.0); @@ -354,19 +388,24 @@ impl CheckResultDialog { Status::Checking => { ui.horizontal(|ui| { ui.spinner(); - ui.label(RichText::new("Checking for updates…").size(12.5).color(text_primary)); + ui.label(RichText::new(t("Checking for updates…", &[])).size(12.5).color(text_primary)); }); } Status::UpToDate => { - let text = format!("{} {} is the newest version available.", self.app.name, self.running_version); + let text = t( + "{app} {version} is the newest version available.", + &[("app", self.app.name), ("version", self.running_version)], + ); ui.label(RichText::new(text).size(12.5).color(text_muted)); } Status::Failed(error) => { let alert_color = Color32::from_rgb(225, 95, 30); - ui.label(RichText::new("Could not check for updates.").size(12.5).strong().color(alert_color)); + ui.label(RichText::new(t("Could not check for updates.", &[])).size(12.5).strong().color(alert_color)); ui.add(egui::Label::new(RichText::new(error).size(12.0).color(text_muted)).selectable(true)); ui.label( - RichText::new(format!("All releases are listed at {}", self.app.releases_url())).size(11.5).color(text_muted), + RichText::new(t("All releases are listed at {url}", &[("url", &self.app.releases_url())])) + .size(11.5) + .color(text_muted), ); } Status::Idle => {} @@ -379,7 +418,7 @@ impl CheckResultDialog { ui.add_space(8.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.set_min_height(26.0); - let ok = egui::Button::new(RichText::new("OK").size(10.5).strong().monospace().color(on_fill)) + let ok = egui::Button::new(RichText::new(button(t, "OK")).size(10.5).strong().monospace().color(on_fill)) .fill(fill) .corner_radius(egui::CornerRadius::ZERO) .min_size(Vec2::new(65.0, 26.0)); @@ -387,7 +426,7 @@ impl CheckResultDialog { response = CheckResultResponse::Close; } if matches!(status, Status::Failed(_)) { - let again = egui::Button::new(RichText::new("CHECK AGAIN").size(10.5).monospace().color(text_primary)) + let again = egui::Button::new(RichText::new(button(t, "Check Again")).size(10.5).monospace().color(text_primary)) .corner_radius(egui::CornerRadius::ZERO) .min_size(Vec2::new(100.0, 26.0)); if ui.add(again).clicked() { @@ -500,6 +539,7 @@ mod tests { open: true, status_message: None, is_installing: false, + translate: english, } } @@ -938,4 +978,59 @@ mod tests { let escape = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; assert_eq!(run(&dark_context(), &Status::UpToDate, vec![escape]).0, CheckResultResponse::Close); } + + /// A pseudo-language for the tests: every string bracketed, its values reordered first, so a + /// string the card draws without asking the app shows up as plain English. + fn pseudo(text: &str, args: &[(&str, &str)]) -> String { + let values: Vec<&str> = args.iter().map(|(_, value)| *value).collect(); + format!("[{} | {}]", values.join(", "), text.split('{').next().unwrap_or("")) + } + + /// Each painted line of a translated card is the app's (bracketed), or a value the card + /// doesn't translate (the badge initial, the app name, an error). + fn assert_all_translated(painted: &str, untranslated: &[&str]) { + for line in painted.lines() { + assert!(line.starts_with('[') || untranslated.contains(&line), "{line:?} is not translated in {painted:?}"); + } + } + + #[test] + fn the_prompt_draws_its_words_through_the_apps_translator() { + for (self_updating, installing) in [(false, false), (true, false), (true, true)] { + let ctx = dark_context(); + let mut prompt = StartupPrompt { is_installing: installing, ..prompt_for(self_updating) }.with_translator(pseudo); + frame(&ctx, &mut prompt, Vec::new()); + let (_, output) = frame(&ctx, &mut prompt, Vec::new()); + let painted = painted_text(&output); + assert_all_translated(&painted, &["P", "PhotoCraft"]); + // Values reach the translation; buttons are looked up in title case and drawn in capitals. + assert!(painted.contains("[0.1.0, 0.2.0 | v]") && painted.contains("[ArtCraft | by ]"), "{painted}"); + let button = match (self_updating, installing) { + (_, true) => "[ | DISMISS]", + (true, false) => "[ | UPDATE AND RESTART]", + (false, false) => "[ | DOWNLOAD]", + }; + assert!(painted.contains(button) && painted.contains("[ | UPDATE]"), "{painted}"); + } + } + + #[test] + fn the_result_dialog_draws_its_words_through_the_apps_translator() { + let dialog = CheckResultDialog::new(&PHOTOCRAFT, "0.1.0").with_translator(pseudo); + for status in [Status::Checking, Status::UpToDate, Status::Failed("no network".into())] { + let ctx = dark_context(); + let mut output = None; + for _ in 0..2 { + let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen()), ..RawInput::default() }, |ui| { + dialog.show(ui.ctx(), &status); + }); + out.textures_delta.clear(); + output = Some(out); + } + let painted = output.map(|o| painted_text(&o)).unwrap_or_default(); + // The version line is a number, and a failure's reason comes from the network as it is. + assert_all_translated(&painted, &["P", "PhotoCraft", "v0.1.0", "no network"]); + assert!(painted.contains("[ArtCraft | by ]"), "{painted}"); + } + } } diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index 4f14c0b5..18d31607 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -29,6 +29,7 @@ pub mod source; pub mod status; pub mod suite; pub mod time; +pub mod translate; pub mod version; #[cfg(feature = "egui")] diff --git a/crates/update/src/translate.rs b/crates/update/src/translate.rs new file mode 100644 index 00000000..3bb89ceb --- /dev/null +++ b/crates/update/src/translate.rs @@ -0,0 +1,90 @@ +//! The words the update dialogs and the [`Updater`](crate::updater::Updater) show, in the app's +//! interface language. +//! +//! The crate keeps its text in English, and that English text is the lookup key, as in the +//! craft-i18n catalogs. An app with translations passes a [`Translate`] function to +//! `StartupPrompt::with_translator`, `CheckResultDialog::with_translator` and +//! `Updater::with_translator`. The dialogs call it each time they draw a string, so a change of +//! interface language shows on the next frame. Without one the text stays in English. +//! +//! A template names its values in braces (`{app} {version} is available…`) and the function +//! gets the values beside it, so a translation can put them in another order. An app built on +//! craft-i18n passes `|text, args| craft_i18n::fmt(i18n.t(lang, text), args)`. That also keeps a +//! Latin app name in its place in a right-to-left sentence. Button labels are looked up in title +//! case (`Later`, `Check Again`) and drawn in capitals, so the rows the app already has for its +//! own buttons serve here too. +//! +//! The card otherwise draws only ASCII, so a font that lacks a glyph can't trip it. An app that +//! translates it must have fonts for its translations, which it needs for its own interface anyway. + +/// The English `text` (a key) in the interface language, its `{name}` placeholders filled from +/// `args`. A `fn`, not a closure, so the dialogs stay `Copy` and `Debug`. An app's lookup reads +/// the current language from its own state. +pub type Translate = fn(text: &str, args: &[(&str, &str)]) -> String; + +/// The [`Translate`] used when the app gives none: the English text, its placeholders filled. +pub fn english(text: &str, args: &[(&str, &str)]) -> String { + fill(text, args) +} + +/// `template` with each `{name}` in `args` replaced by its value. A placeholder that `args` +/// lacks stays as written. An inserted value is never read as a template, so a value containing +/// `{app}` stays intact. +pub fn fill(template: &str, args: &[(&str, &str)]) -> String { + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(open) = rest.find('{') { + let (before, from_brace) = rest.split_at(open); + out.push_str(before); + let value = from_brace.find('}').and_then(|close| { + let name = from_brace.get(1..close)?; + args.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value, close)) + }); + match value { + Some((value, close)) => { + out.push_str(value); + rest = from_brace.get(close + 1..).unwrap_or(""); + } + None => { + out.push('{'); + rest = from_brace.get(1..).unwrap_or(""); + } + } + } + out.push_str(rest); + out +} + +/// A button label: `label` (title case, the lookup key) translated, then in capitals, as the card +/// draws its buttons. +pub(crate) fn button(translate: Translate, label: &str) -> String { + translate(label, &[]).to_uppercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn placeholders_are_filled_and_values_are_not_templates() { + assert_eq!(fill("{app} {version} is out", &[("app", "PhotoCraft"), ("version", "0.2.0")]), "PhotoCraft 0.2.0 is out"); + // Reordered, as a translation may do. + assert_eq!(fill("{version}: {app}", &[("app", "A"), ("version", "1")]), "1: A"); + // Unknown and unclosed placeholders stay; a value holding a placeholder stays as it is. + assert_eq!(fill("{nope} and {app", &[("app", "X")]), "{nope} and {app"); + assert_eq!(fill("by {developer}", &[("developer", "{developer}")]), "by {developer}"); + assert_eq!(fill("é {app} ü", &[("app", "日本")]), "é 日本 ü"); + assert_eq!(fill("", &[]), ""); + assert_eq!(fill("{}", &[]), "{}"); + } + + #[test] + fn english_is_the_default_and_buttons_are_capitals() { + assert_eq!(english("{app} is ready", &[("app", "PdfCraft")]), "PdfCraft is ready"); + assert_eq!(button(english, "Check Again"), "CHECK AGAIN"); + fn french(text: &str, args: &[(&str, &str)]) -> String { + fill(if text == "Later" { "Plus tard" } else { text }, args) + } + assert_eq!(button(french, "Later"), "PLUS TARD"); + } +} diff --git a/crates/update/src/updater.rs b/crates/update/src/updater.rs index a20f1405..c213af18 100644 --- a/crates/update/src/updater.rs +++ b/crates/update/src/updater.rs @@ -15,6 +15,7 @@ use std::time::Duration; use crate::dialog::{StartupPrompt, StartupPromptResponse}; use crate::pins::{Choice, Pins}; use crate::release::Release; +use crate::translate::{Translate, english}; use crate::{App, Http, Paths, Platform, Stop, check_for_update, self_update}; /// Asks for the newest release (blocking; it runs on its own thread). Tests stand in for the network @@ -65,6 +66,8 @@ pub struct Updater { pins_path: Option, /// The launcher's choice for this app when it is not stable, as of the last check or offer. managed: Option, + /// Puts the prompt and its messages in the interface language ([`crate::translate`]). + translate: Translate, } impl Updater { @@ -85,9 +88,28 @@ impl Updater { installed: false, pins_path: None, managed: None, + translate: english, } } + /// Words the startup prompt and the messages it shows with `translate`, in the app's + /// interface language ([`crate::translate`]). Errors from the network stay as they came. + #[must_use] + pub fn with_translator(mut self, translate: Translate) -> Self { + self.translate = translate; + self + } + + /// The startup prompt for `release`, in the interface language. + fn prompt_for(&self, release: Release) -> StartupPrompt { + StartupPrompt::new(self.app, release, self.version.clone()).with_translator(self.translate) + } + + /// "The update is installed…", in the interface language. + fn installed_message(&self) -> String { + (self.translate)("The update is installed. Close {app} to finish; it restarts by itself.", &[("app", self.app.name)]) + } + /// Replaces the network with `source` (tests). pub fn with_source(mut self, source: CheckSource) -> Self { self.source = Some(source); @@ -142,7 +164,7 @@ impl Updater { /// Opens the startup prompt for a release the app found itself. pub fn offer(&mut self, release: Release) { if self.install.is_none() && !self.installed && !self.read_choice() { - self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + self.prompt = Some(self.prompt_for(release)); } } @@ -211,7 +233,7 @@ impl Updater { Ok(Some(release)) => { self.status = Status::Idle; if self.prompt.is_none() && self.install.is_none() && !self.installed { - self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + self.prompt = Some(self.prompt_for(release)); } } Ok(None) => self.status = if requested { Status::UpToDate } else { Status::Idle }, @@ -253,10 +275,10 @@ impl Updater { Ok(()) => { self.installed = true; self.restart = true; + let message = self.installed_message(); if let Some(prompt) = &mut self.prompt { prompt.is_installing = false; - prompt.status_message = - Some(format!("The update is installed. Close {} to finish; it restarts by itself.", self.app.name)); + prompt.status_message = Some(message); } } Err(e) => { @@ -265,7 +287,7 @@ impl Updater { if self.prompt.is_none() && let Some(release) = release { - self.prompt = Some(StartupPrompt::new(self.app, release, self.version.clone())); + self.prompt = Some(self.prompt_for(release)); } if let Some(prompt) = &mut self.prompt { prompt.is_installing = false; @@ -316,7 +338,7 @@ impl Updater { if can_start { self.start_install(ctx); } else if let Some(prompt) = &mut self.prompt { - prompt.status_message = Some("Save or close your open work first; the update restarts the app.".into()); + prompt.status_message = Some((self.translate)("Save or close your open work first; the update restarts the app.", &[])); } } @@ -325,13 +347,14 @@ impl Updater { if self.install.is_none() && !self.installed && self.read_choice() { return; } + let (installed_message, t) = (self.installed_message(), self.translate); let Some(prompt) = &mut self.prompt else { return }; if self.installed { - prompt.status_message = Some(format!("The update is installed. Close {} to finish; it restarts by itself.", self.app.name)); + prompt.status_message = Some(installed_message); return; } let Some(installed) = self_update::current(self.app) else { - prompt.status_message = Some("Cannot auto-update this installation type. Please download manually.".into()); + prompt.status_message = Some(t("Cannot auto-update this installation type. Please download manually.", &[])); prompt.is_installing = false; return; };