From a9e5afebf8a43040c20a68e685086bbf81743fbe Mon Sep 17 00:00:00 2001 From: cdhdt Date: Mon, 2 Nov 2026 10:55:49 +0100 Subject: [PATCH] craft-update: the update dialogs take the app's translator The startup prompt, the Check for Updates result card and the messages the Updater puts in the prompt were English whatever the app's interface language (storytold/wordcraft#5257). `StartupPrompt`, `CheckResultDialog` and `Updater` get `with_translator(Translate)`: a `fn(&str, &[(&str, &str)]) -> String` that gets the English text (the key, as in the craft-i18n catalogs) and its `{name}` values, so translations may reorder them and an app on craft-i18n passes `craft_i18n::fmt(i18n.t(lang, text), args)`. It is called when the card draws, so a language change shows on the next frame. Buttons are looked up in title case and drawn in capitals, so the apps' existing button rows serve. Without a translator nothing changes. --- crates/update/src/dialog.rs | 161 ++++++++++++++++++++++++++------- crates/update/src/lib.rs | 1 + crates/update/src/translate.rs | 90 ++++++++++++++++++ crates/update/src/updater.rs | 39 ++++++-- 4 files changed, 250 insertions(+), 41 deletions(-) create mode 100644 crates/update/src/translate.rs 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; };