From c5a1cbfb5f9049f9270955caffb5bee5997aebf7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 04:39:40 +0000 Subject: [PATCH] update: CheckResultDialog shows the result of a manual update check --- Cargo.lock | 2 + crates/update/Cargo.toml | 5 + crates/update/src/dialog.rs | 345 +++++++++++++++++----- crates/update/src/lib.rs | 2 +- crates/update/tests/dialog_screenshots.rs | 78 +++++ 5 files changed, 360 insertions(+), 72 deletions(-) create mode 100644 crates/update/tests/dialog_screenshots.rs diff --git a/Cargo.lock b/Cargo.lock index c4d724bc..4fcd223b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1073,6 +1073,8 @@ dependencies = [ "chrono", "dunce", "egui", + "egui_kittest", + "epaint_default_fonts", "flate2", "getrandom 0.3.4", "image", diff --git a/crates/update/Cargo.toml b/crates/update/Cargo.toml index bdbb997b..4e074060 100644 --- a/crates/update/Cargo.toml +++ b/crates/update/Cargo.toml @@ -14,6 +14,8 @@ workspace = true default = [] egui = ["dep:egui", "dep:image"] local-mirror = [] +# tests/dialog_screenshots.rs: renders the dialogs to PNG with egui_kittest's wgpu renderer. +snapshot = ["egui", "egui_kittest/wgpu"] [dependencies] egui = { workspace = true, optional = true } @@ -31,6 +33,9 @@ semver = { version = "1", features = ["serde"] } [dev-dependencies] flate2 = "1" +egui_kittest = "0.37" +# egui's own Hack and Ubuntu Light for the screenshots (egui is built without default fonts). +epaint_default_fonts = "0.37" [target.'cfg(unix)'.dependencies] flate2 = "1" diff --git a/crates/update/src/dialog.rs b/crates/update/src/dialog.rs index f63da086..66dd956c 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::updater::Status; /// What the user chose in the startup update prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -45,24 +46,14 @@ impl StartupPrompt { let mut response = StartupPromptResponse::Pending; - // Modal dimmed background overlay; it senses clicks so they don't reach widgets underneath. - let screen_rect = ctx.content_rect(); - egui::Area::new(egui::Id::unique("craft_update_backdrop")).order(egui::Order::Middle).fixed_pos(screen_rect.min).show(ctx, |ui| { - let (rect, _) = ui.allocate_exact_size(screen_rect.size(), egui::Sense::click_and_drag()); - ui.painter().rect_filled(rect, 0.0, Color32::from_black_alpha(150)); - }); + backdrop(ctx); if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { response = StartupPromptResponse::Later; } - let is_dark = ctx.theme() == egui::Theme::Dark; - let card_bg = if is_dark { Color32::from_rgb(26, 26, 28) } else { Color32::from_rgb(255, 255, 255) }; - let border_color = if is_dark { Color32::from_rgb(52, 52, 56) } else { Color32::from_rgb(215, 215, 220) }; - let text_primary = if is_dark { Color32::from_rgb(245, 245, 248) } else { Color32::from_rgb(20, 20, 25) }; - let text_muted = if is_dark { Color32::from_rgb(150, 150, 160) } else { Color32::from_rgb(105, 105, 115) }; - let fill = self.app.accent.map_or(DEFAULT_ACCENT, |[r, g, b]| Color32::from_rgb(r, g, b)); - let on_fill = ink_on(fill); + let pal = Palette::new(ctx, self.app); + let Palette { is_dark, card_bg, border_color, text_primary, text_muted, fill, on_fill } = pal; egui::Area::new(egui::Id::unique("craft_update.startup_prompt")) .anchor(Align2::CENTER_CENTER, Vec2::ZERO) @@ -75,65 +66,11 @@ impl StartupPrompt { .inner_margin(egui::Margin::symmetric(20, 18)) .show(ui, |ui| { ui.set_width(400.0); - // The host app's widget style isn't ours to rely on (a light theme's hover can be - // dark under this card's dark text), so the prompt's buttons get their own states. - let widgets = &mut ui.visuals_mut().widgets; - let (hover_bg, press_bg) = button_states(is_dark); - for (state, bg) in [(&mut widgets.inactive, card_bg), (&mut widgets.hovered, hover_bg), (&mut widgets.active, press_bg)] { - state.weak_bg_fill = bg; - state.bg_fill = bg; - state.bg_stroke = egui::Stroke::new(1.0, border_color); + 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) { + response = StartupPromptResponse::Later; } - // egui draws a focused button (and a pressed one) in the active state: its border is - // the focus ring, in the body text colour so it shows on the card and around a fill. - widgets.active.bg_stroke = egui::Stroke::new(2.0, text_primary); - ui.spacing_mut().item_spacing = Vec2::new(8.0, 6.0); - - // Header row: app icon (or its initial in a badge) + title + close - ui.horizontal(|ui| { - let (badge_rect, _) = ui.allocate_exact_size(Vec2::new(34.0, 34.0), egui::Sense::hover()); - if let Some(icon) = icon_texture(ui.ctx(), self.app) { - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - ui.painter().image(icon.id(), badge_rect, uv, Color32::WHITE); - } else { - ui.painter().rect_filled(badge_rect, egui::CornerRadius::ZERO, fill); - let initial = self.app.name.chars().next().map(|c| c.to_string()).unwrap_or_else(|| "A".to_string()); - ui.painter().text(badge_rect.center(), Align2::CENTER_CENTER, initial, egui::FontId::monospace(18.0), on_fill); - } - - ui.add_space(4.0); - - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 2.0; - ui.horizontal(|ui| { - ui.label(RichText::new(self.app.name).size(15.0).strong().color(text_primary)); - ui.label(RichText::new(format!("by {}", self.app.developer)).size(11.5).color(text_muted)); - // 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)); - }); - }); - // ASCII only, like the rest of the card: an app whose fonts lack a glyph may panic - // instead of drawing a box, and this prompt shows up unasked. - let version_pill = format!("v{} to {}", self.running_version, self.release.version_label()); - ui.label(RichText::new(version_pill).size(11.0).monospace().color(text_muted)); - }); - - 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")); - 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); - ui.painter().line_segment([center + Vec2::new(-reach, -reach), center + Vec2::new(reach, reach)], stroke); - ui.painter().line_segment([center + Vec2::new(-reach, reach), center + Vec2::new(reach, -reach)], stroke); - if close.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { - response = StartupPromptResponse::Later; - } - }); - }); ui.add_space(8.0); @@ -236,6 +173,236 @@ impl StartupPrompt { } } +/// The card's colours for the current theme and app. +#[derive(Clone, Copy)] +struct Palette { + is_dark: bool, + card_bg: Color32, + border_color: Color32, + text_primary: Color32, + text_muted: Color32, + /// The app's accent. + fill: Color32, + /// Text on `fill`. + on_fill: Color32, +} + +impl Palette { + fn new(ctx: &egui::Context, app: &App) -> Self { + let is_dark = ctx.theme() == egui::Theme::Dark; + let fill = app.accent.map_or(DEFAULT_ACCENT, |[r, g, b]| Color32::from_rgb(r, g, b)); + Self { + is_dark, + card_bg: if is_dark { Color32::from_rgb(26, 26, 28) } else { Color32::from_rgb(255, 255, 255) }, + border_color: if is_dark { Color32::from_rgb(52, 52, 56) } else { Color32::from_rgb(215, 215, 220) }, + text_primary: if is_dark { Color32::from_rgb(245, 245, 248) } else { Color32::from_rgb(20, 20, 25) }, + text_muted: if is_dark { Color32::from_rgb(150, 150, 160) } else { Color32::from_rgb(105, 105, 115) }, + fill, + on_fill: ink_on(fill), + } + } +} + +/// The dimmed modal backdrop; it senses clicks so they don't reach widgets underneath. True when +/// it was clicked (a click outside the card). +fn backdrop(ctx: &egui::Context) -> bool { + let screen_rect = ctx.content_rect(); + egui::Area::new(egui::Id::unique("craft_update_backdrop")) + .order(egui::Order::Middle) + .fixed_pos(screen_rect.min) + .show(ctx, |ui| { + let (rect, response) = ui.allocate_exact_size(screen_rect.size(), egui::Sense::click_and_drag()); + ui.painter().rect_filled(rect, 0.0, Color32::from_black_alpha(150)); + response.clicked() + }) + .inner +} + +/// The host app's widget style isn't ours to rely on (a light theme's hover can be dark under +/// this card's dark text), so the card's buttons get their own states. +fn style_card(ui: &mut egui::Ui, pal: &Palette) { + let widgets = &mut ui.visuals_mut().widgets; + let (hover_bg, press_bg) = button_states(pal.is_dark); + for (state, bg) in [(&mut widgets.inactive, pal.card_bg), (&mut widgets.hovered, hover_bg), (&mut widgets.active, press_bg)] { + state.weak_bg_fill = bg; + state.bg_fill = bg; + state.bg_stroke = egui::Stroke::new(1.0, pal.border_color); + } + // egui draws a focused button (and a pressed one) in the active state: its border is + // the focus ring, in the body text colour so it shows on the card and around a fill. + widgets.active.bg_stroke = egui::Stroke::new(2.0, pal.text_primary); + ui.spacing_mut().item_spacing = Vec2::new(8.0, 6.0); +} + +/// 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 { + let Palette { text_primary, text_muted, fill, on_fill, .. } = *pal; + let mut closed = false; + ui.horizontal(|ui| { + let (badge_rect, _) = ui.allocate_exact_size(Vec2::new(34.0, 34.0), egui::Sense::hover()); + if let Some(icon) = icon_texture(ui.ctx(), app) { + let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); + ui.painter().image(icon.id(), badge_rect, uv, Color32::WHITE); + } else { + ui.painter().rect_filled(badge_rect, egui::CornerRadius::ZERO, fill); + let initial = app.name.chars().next().map(|c| c.to_string()).unwrap_or_else(|| "A".to_string()); + ui.painter().text(badge_rect.center(), Align2::CENTER_CENTER, initial, egui::FontId::monospace(18.0), on_fill); + } + + ui.add_space(4.0); + + ui.vertical(|ui| { + 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)); + 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)); + }); + } + }); + // ASCII only, like the rest of the card: an app whose fonts lack a glyph may panic + // instead of drawing a box, and the startup prompt shows up unasked. + ui.label(RichText::new(second_line).size(11.0).monospace().color(text_muted)); + }); + + 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")); + 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); + ui.painter().line_segment([center + Vec2::new(-reach, -reach), center + Vec2::new(reach, reach)], stroke); + ui.painter().line_segment([center + Vec2::new(-reach, reach), center + Vec2::new(reach, -reach)], stroke); + if close.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { + closed = true; + } + }); + }); + closed +} + +/// What the user chose in the result dialog of a manual update check. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckResultResponse { + /// Dialog is still open and waiting for user input. + None, + /// OK, the close button, Esc or a click outside: the app stops showing the dialog. + Close, + /// Failed state only: the app starts another requested check. + CheckAgain, +} + +/// The result of a manual "Check for Updates", in the same card as [`StartupPrompt`]: a spinner +/// while checking, "newest version" when up to date, the error and a retry when it failed. +/// +/// An app shows it while a requested check is in view: open it when the user starts a check +/// (`Updater::check_now`), call [`show`](Self::show) every frame with `updater.status()` until it +/// returns [`CheckResultResponse::Close`], then stop calling it. On +/// [`CheckResultResponse::CheckAgain`] start another requested check and keep showing it. An +/// available update goes to [`StartupPrompt`] instead; the app closes this dialog when the prompt opens. +#[derive(Clone, Copy, Debug)] +pub struct CheckResultDialog { + app: &'static App, + running_version: &'static str, +} + +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 } + } + + /// Draws the modal card for `status` and returns what the user chose. [`Status::Idle`] draws + /// nothing and returns [`CheckResultResponse::None`]. + pub fn show(&self, ctx: &egui::Context, status: &Status) -> CheckResultResponse { + if matches!(status, Status::Idle) { + return CheckResultResponse::None; + } + let mut response = CheckResultResponse::None; + if backdrop(ctx) { + response = CheckResultResponse::Close; + } + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + response = CheckResultResponse::Close; + } + + let pal = Palette::new(ctx, self.app); + let Palette { card_bg, border_color, text_primary, text_muted, fill, on_fill, .. } = pal; + egui::Area::new(egui::Id::unique("craft_update.check_result")).anchor(Align2::CENTER_CENTER, Vec2::ZERO).order(egui::Order::Foreground).show( + ctx, + |ui| { + egui::Frame::new() + .fill(card_bg) + .stroke(egui::Stroke::new(1.0, border_color)) + .corner_radius(egui::CornerRadius::ZERO) + .inner_margin(egui::Margin::symmetric(20, 18)) + .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)) { + response = CheckResultResponse::Close; + } + ui.add_space(8.0); + + match status { + Status::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("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); + 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.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), + ); + } + Status::Idle => {} + } + + if !matches!(status, Status::Checking) { + ui.add_space(10.0); + let (hairline_rect, _) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 1.0), egui::Sense::hover()); + ui.painter().hline(hairline_rect.x_range(), hairline_rect.top(), egui::Stroke::new(1.0, border_color)); + 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)) + .fill(fill) + .corner_radius(egui::CornerRadius::ZERO) + .min_size(Vec2::new(65.0, 26.0)); + if ui.add(ok).clicked() { + response = CheckResultResponse::Close; + } + if matches!(status, Status::Failed(_)) { + let again = egui::Button::new(RichText::new("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() { + response = CheckResultResponse::CheckAgain; + } + } + }); + } + }); + }, + ); + response + } +} + /// Backgrounds of an unfilled button (LATER, DISMISS) when hovered and when pressed: a step from /// the card, so its text keeps the contrast it has at rest. fn button_states(is_dark: bool) -> (Color32, Color32) { @@ -735,4 +902,40 @@ mod tests { assert!(output.shapes.is_empty(), "closed prompt painted {} shapes", output.shapes.len()); assert!(painted_text(&output).is_empty()); } + + #[test] + fn check_result_paints_each_state_and_escape_closes() { + let dialog = CheckResultDialog::new(&PHOTOCRAFT, "0.1.0"); + let run = |ctx: &Context, status: &Status, events: Vec| { + // The first frame of an area only sizes it; the second paints. + let mut frames = [Vec::new(), events].into_iter().map(|events| { + let mut response = CheckResultResponse::None; + let mut output = ctx.run_ui(RawInput { screen_rect: Some(screen()), events, ..RawInput::default() }, |ui| { + response = dialog.show(ui.ctx(), status); + }); + output.textures_delta.clear(); + (response, output) + }); + frames.next(); + frames.next().unwrap() + }; + let ctx = dark_context(); + let (_, checking) = run(&ctx, &Status::Checking, Vec::new()); + assert!(painted_text(&checking).contains("Checking for updates…")); + assert!(!painted_text(&checking).contains("UPDATE"), "no update pill on a result"); + let (_, up_to_date) = run(&ctx, &Status::UpToDate, Vec::new()); + let text = painted_text(&up_to_date); + assert!(text.contains("PhotoCraft 0.1.0 is the newest version available.") && text.contains("OK"), "{text}"); + let (response, failed) = run(&ctx, &Status::Failed("no network".into()), Vec::new()); + let text = painted_text(&failed); + for want in ["Could not check for updates.", "no network", "All releases are listed at https://github.com/", "CHECK AGAIN", "OK"] { + assert!(text.contains(want), "{want} missing in {text}"); + } + assert_eq!(response, CheckResultResponse::None); + let (_, idle) = run(&ctx, &Status::Idle, Vec::new()); + assert!(painted_text(&idle).is_empty()); + + 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); + } } diff --git a/crates/update/src/lib.rs b/crates/update/src/lib.rs index fab644dd..4f14c0b5 100644 --- a/crates/update/src/lib.rs +++ b/crates/update/src/lib.rs @@ -48,7 +48,7 @@ pub use time::parse_rfc3339; pub use version::{Version, is_newer, parse as parse_version}; #[cfg(feature = "egui")] -pub use dialog::{StartupPrompt, StartupPromptResponse}; +pub use dialog::{CheckResultDialog, CheckResultResponse, StartupPrompt, StartupPromptResponse}; /// Checks for an update against GitHub releases for the given app. pub fn check_for_update(app: &App, http: &Http, current_version: &str) -> Result, String> { diff --git a/crates/update/tests/dialog_screenshots.rs b/crates/update/tests/dialog_screenshots.rs new file mode 100644 index 00000000..19123b82 --- /dev/null +++ b/crates/update/tests/dialog_screenshots.rs @@ -0,0 +1,78 @@ +#![cfg(feature = "snapshot")] +//! Renders the update dialogs to PNG, light and dark at 1.5x: +//! `CRAFT_UPDATE_SNAPSHOT_DIR=~/.cache/cl-update-shots cargo test -p craft-update --features snapshot --test dialog_screenshots` +//! Without the directory variable the test does nothing; these are not golden comparisons. + +use std::path::PathBuf; + +use craft_update::dialog::{CheckResultDialog, StartupPrompt}; +use craft_update::release::Release; +use craft_update::suite::PHOTOCRAFT; +use craft_update::updater::Status; +use egui::Theme; +use egui_kittest::Harness; + +fn fonts(ctx: &egui::Context) { + let mut defs = egui::FontDefinitions::default(); + for (name, bytes, family) in [ + ("Hack", epaint_default_fonts::HACK_REGULAR, egui::FontFamily::Monospace), + ("Ubuntu", epaint_default_fonts::UBUNTU_LIGHT, egui::FontFamily::Proportional), + ] { + defs.font_data.insert(name.into(), std::sync::Arc::new(egui::FontData::from_static(bytes))); + defs.families.entry(family).or_default().push(name.into()); + } + // Both faces serve both families: the card uses monospace labels and proportional text. + for family in defs.families.values_mut() { + for name in ["Ubuntu", "Hack"] { + if !family.iter().any(|f| f == name) { + family.push(name.into()); + } + } + } + ctx.set_fonts(defs); +} + +static APP: craft_update::App = PHOTOCRAFT; + +#[test] +fn dialog_screenshots() { + let Some(dir) = std::env::var_os("CRAFT_UPDATE_SNAPSHOT_DIR").map(PathBuf::from) else { return }; + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + let long_error = "couldn't reach https://api.github.com/repos/storytold/photocraft/releases/latest: \ + connection timed out after 30 seconds (os error 110); check the network or proxy settings"; + let states: [(&str, Option); 4] = [ + ("checking", Some(Status::Checking)), + ("up-to-date", Some(Status::UpToDate)), + ("failed", Some(Status::Failed(long_error.into()))), + ("startup-prompt", None), + ]; + for (name, status) in states { + for (theme_name, theme) in [("light", Theme::Light), ("dark", Theme::Dark)] { + let dialog = CheckResultDialog::new(&APP, "0.1.0"); + let mut prompt = StartupPrompt::new( + &APP, + Release { version: Some(semver::Version::new(0, 2, 0)), tag: "v0.2.0".into(), ..Release::default() }, + "0.1.0", + ); + prompt.is_self_updating = false; + let mut h = Harness::builder() + .with_size(egui::vec2(560.0, 360.0)) + .with_pixels_per_point(1.5) + .with_theme(theme) + .with_context_setup(fonts) + .wgpu() + .build_ui(|ui| match &status { + Some(status) => { + dialog.show(ui.ctx(), status); + } + None => { + prompt.show(ui.ctx()); + } + }); + h.run_steps(4); + let image = h.render().unwrap_or_else(|e| panic!("render {name}: {e}")); + let path = dir.join(format!("update-{name}-{theme_name}.png")); + image.save(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + } + } +}