Files
craft-libs/crates/update/src/dialog.rs
T

942 lines
47 KiB
Rust

//! Startup update prompt dialog for egui frontends.
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)]
pub enum StartupPromptResponse {
/// Dialog is still open and waiting for user input.
Pending,
/// User chose to update and restart now.
UpdateAndRestart,
/// User chose to download manually via browser.
Download,
/// User dismissed the dialog for now.
Later,
}
/// State for the startup update prompt dialog.
#[derive(Clone, Debug)]
pub struct StartupPrompt {
pub app: &'static App,
pub release: Release,
pub running_version: String,
pub is_self_updating: bool,
pub open: bool,
pub status_message: Option<String>,
pub is_installing: bool,
}
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<String>) -> 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 }
}
/// Renders the modal window on the given egui context.
pub fn show(&mut self, ctx: &egui::Context) -> StartupPromptResponse {
if !self.open {
return StartupPromptResponse::Later;
}
let mut response = StartupPromptResponse::Pending;
backdrop(ctx);
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
response = StartupPromptResponse::Later;
}
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)
.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);
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;
}
ui.add_space(8.0);
if let Some(msg) = &self.status_message {
let alert_bg = if is_dark { Color32::from_rgb(50, 30, 20) } else { Color32::from_rgb(255, 242, 235) };
let alert_color = Color32::from_rgb(225, 95, 30);
egui::Frame::new()
.fill(alert_bg)
.stroke(egui::Stroke::new(1.0, alert_color))
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::symmetric(10, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(msg).size(12.0).color(alert_color));
});
ui.add_space(4.0);
}
if self.is_installing {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("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());
ui.label(RichText::new(desc).size(12.5).color(text_muted));
}
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);
// Footer actions: a row as tall as the buttons, everything centred in it, so the link
// lines up with them (a plain horizontal row puts it at the top).
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 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));
// Keyboard focus (Tab, then Enter or Space opens it): the buttons' 2 px ring.
if link.has_focus() {
let ring = egui::Stroke::new(2.0, text_primary);
ui.painter().rect_stroke(link.rect.expand(2.0), egui::CornerRadius::ZERO, ring, egui::StrokeKind::Outside);
}
if link.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(&self.release.html_url));
}
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))
.corner_radius(egui::CornerRadius::ZERO)
.min_size(Vec2::new(72.0, 26.0));
if ui.add(dismiss_btn).clicked() {
response = StartupPromptResponse::Later;
}
} 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));
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));
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))
.corner_radius(egui::CornerRadius::ZERO)
.min_size(Vec2::new(65.0, 26.0));
if ui.add(later_btn).clicked() {
response = StartupPromptResponse::Later;
}
}
});
});
});
});
if response == StartupPromptResponse::Later {
self.open = false;
}
response
}
}
/// 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) {
if is_dark {
(Color32::from_rgb(44, 44, 48), Color32::from_rgb(58, 58, 64))
} else {
(Color32::from_rgb(236, 236, 241), Color32::from_rgb(222, 222, 230))
}
}
/// The prompt's blue for an app that names no accent ([`App::accent`]).
const DEFAULT_ACCENT: Color32 = Color32::from_rgb(52, 120, 246);
/// WCAG relative luminance of `c`: 0 for black to 1 for white.
fn luminance(c: Color32) -> f32 {
let channel = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b())
}
/// WCAG contrast ratio of two colours: 1 (none) to 21 (black on white).
fn contrast(a: Color32, b: Color32) -> f32 {
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
/// Text for a label on `fill`: white or black, whichever contrasts more. Every Craft app accent
/// then reaches WCAG AA's 4.5:1 for the prompt's small labels (5:1 at the least).
fn ink_on(fill: Color32) -> Color32 {
if contrast(fill, Color32::WHITE) >= contrast(fill, Color32::BLACK) { Color32::WHITE } else { Color32::BLACK }
}
/// The largest icon the prompt decodes, a side; app icons are 128 px.
const ICON_MAX_SIDE: u32 = 1024;
/// `app`'s icon as a texture, decoded once per context and app; `None` when the app has none or
/// it doesn't decode, and the prompt shows the app's initial instead.
fn icon_texture(ctx: &egui::Context, app: &App) -> Option<egui::TextureHandle> {
let png = app.icon_png?;
let id = egui::Id::unique(("craft_update.icon", app.id));
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)) {
return cached;
}
// The renderer's own limit too: load_texture panics (debug) on a texture larger than it takes.
let max_side = u32::try_from(ctx.input(|i| i.max_texture_side)).unwrap_or(u32::MAX).min(ICON_MAX_SIDE);
let texture =
decode_icon(png, max_side).map(|image| ctx.load_texture(format!("craft_update.icon.{}", app.id), image, egui::TextureOptions::LINEAR));
ctx.data_mut(|d| d.insert_temp(id, texture.clone()));
texture
}
/// A PNG as an RGBA image, or `None` when it doesn't decode or is over `max_side` a side.
fn decode_icon(png: &[u8], max_side: u32) -> Option<egui::ColorImage> {
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png);
let mut limits = image::Limits::default();
limits.max_image_width = Some(max_side);
limits.max_image_height = Some(max_side);
reader.limits(limits);
let rgba = reader.decode().ok()?.to_rgba8();
let size = [usize::try_from(rgba.width()).ok()?, usize::try_from(rgba.height()).ok()?];
Some(egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_raw()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::suite::PHOTOCRAFT;
use egui::{Context, Event, FullOutput, Key, Modifiers, RawInput, Rect};
const DARK_CARD: Color32 = Color32::from_rgb(26, 26, 28);
const DARK_STATUS: Color32 = Color32::from_rgb(50, 30, 20);
fn screen() -> Rect {
Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))
}
/// A dark, animation-free context so a single frame paints final (unfaded) colors.
fn dark_context() -> Context {
let ctx = Context::default();
ctx.set_theme(egui::ThemePreference::Dark);
ctx.global_style_mut(|style| style.animation_time = 0.0);
ctx
}
/// A prompt in a known installation mode, without probing the host for a real install.
fn prompt_for(is_self_updating: bool) -> StartupPrompt {
let release = Release { version: crate::version::parse("0.2.0"), tag: "v0.2.0".to_string(), ..Release::default() };
StartupPrompt {
app: &PHOTOCRAFT,
release,
running_version: "0.1.0".to_string(),
is_self_updating,
open: true,
status_message: None,
is_installing: false,
}
}
fn frame(ctx: &Context, prompt: &mut StartupPrompt, events: Vec<Event>) -> (StartupPromptResponse, FullOutput) {
let mut response = StartupPromptResponse::Pending;
let mut output = ctx.run_ui(RawInput { screen_rect: Some(screen()), events, ..RawInput::default() }, |ui| {
response = prompt.show(ui.ctx());
});
output.textures_delta.clear(); // Headless test: no renderer consumes the font atlas.
(response, output)
}
fn collect_text(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Vec(shapes) => {
for shape in shapes {
collect_text(shape, out);
}
}
egui::Shape::Text(text) => out.push(text.galley.text().to_owned()),
_ => {}
}
}
/// Every string the frame actually painted, one per line.
fn painted_text(output: &FullOutput) -> String {
let mut texts = Vec::new();
for clipped in &output.shapes {
collect_text(&clipped.shape, &mut texts);
}
texts.join("\n")
}
/// The rectangle fills painted this frame, so the card and alert surfaces can be told apart.
fn rect_fills(output: &FullOutput) -> Vec<(Rect, Color32)> {
fn collect(shape: &egui::Shape, out: &mut Vec<(Rect, Color32)>) {
match shape {
egui::Shape::Vec(shapes) => {
for shape in shapes {
collect(shape, out);
}
}
egui::Shape::Rect(rect) => out.push((rect.rect, rect.fill)),
_ => {}
}
}
let mut fills = Vec::new();
for clipped in &output.shapes {
collect(&clipped.shape, &mut fills);
}
fills
}
/// A `width` x `height` PNG of one opaque color.
fn png(width: u32, height: u32, color: [u8; 4]) -> Vec<u8> {
let mut out = std::io::Cursor::new(Vec::new());
image::RgbaImage::from_pixel(width, height, image::Rgba(color)).write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
}
/// The textured meshes painted this frame (the app icon; text is drawn as `Shape::Text`).
fn textured_meshes(output: &FullOutput) -> Vec<egui::TextureId> {
fn collect(shape: &egui::Shape, out: &mut Vec<egui::TextureId>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)),
egui::Shape::Mesh(mesh) if mesh.texture_id != egui::TextureId::default() => out.push(mesh.texture_id),
_ => {}
}
}
let mut ids = Vec::new();
for clipped in &output.shapes {
collect(&clipped.shape, &mut ids);
}
ids
}
#[test]
fn icons_decode_and_bad_ones_are_refused() {
let image = decode_icon(&png(3, 2, [10, 20, 30, 255]), ICON_MAX_SIDE).unwrap();
assert_eq!(image.size, [3, 2]);
assert_eq!(image.pixels[0], Color32::from_rgb(10, 20, 30));
assert!(decode_icon(b"\x89PNG\r\n\x1a\nnot really", ICON_MAX_SIDE).is_none(), "a corrupt PNG must not decode");
assert!(decode_icon(&png(ICON_MAX_SIDE + 1, 1, [0, 0, 0, 255]), ICON_MAX_SIDE).is_none(), "an oversized icon must not decode");
// A renderer that takes smaller textures than the icon: no texture, so the initial is shown.
assert!(decode_icon(&png(3, 2, [0, 0, 0, 255]), 2).is_none(), "an icon over the renderer's limit must not decode");
}
#[test]
fn prompt_shows_the_app_icon_or_else_its_initial() {
static ICON_APP: std::sync::OnceLock<App> = std::sync::OnceLock::new();
let icon_png: &'static [u8] = Box::leak(png(128, 128, [200, 40, 40, 255]).into_boxed_slice());
let with_icon: &'static App = ICON_APP.get_or_init(|| App { icon_png: Some(icon_png), ..PHOTOCRAFT });
let ctx = dark_context();
let mut prompt = StartupPrompt { app: with_icon, ..prompt_for(false) };
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(textured_meshes(&output).len(), 1, "the app icon is drawn");
let badge = rect_fills(&output).iter().any(|(rect, fill)| *fill == DEFAULT_ACCENT && rect.width() == 34.0);
assert!(!badge, "no letter badge beside the icon");
assert!(!painted_text(&output).lines().any(|l| l == "P"), "no initial beside the icon");
// No icon (or one that won't decode): the initial in the accent badge, as before.
let ctx = dark_context();
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
assert!(textured_meshes(&output).is_empty());
assert!(painted_text(&output).lines().any(|l| l == "P"), "the initial stands in for a missing icon");
}
#[test]
fn an_apps_accent_fills_the_buttons_with_readable_text() {
// An amber accent: its buttons take it, with black text (white is under 3:1 on it).
static AMBER: std::sync::OnceLock<App> = std::sync::OnceLock::new();
let amber: &'static App = AMBER.get_or_init(|| App { accent: Some([0xf2, 0xa5, 0x16]), ..PHOTOCRAFT });
let ctx = dark_context();
let mut prompt = StartupPrompt { app: amber, ..prompt_for(false) };
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let fill = Color32::from_rgb(0xf2, 0xa5, 0x16);
assert!(rect_fills(&output).iter().any(|(_, f)| *f == fill), "the accent fills the button and badge");
assert!(!rect_fills(&output).iter().any(|(_, f)| *f == DEFAULT_ACCENT), "no default blue beside an accent");
assert_eq!(ink_on(fill), Color32::BLACK);
// Whichever of white and black contrasts more: black on the default blue, white on
// WordCraft's deeper blue and PdfCraft's red.
assert_eq!(ink_on(DEFAULT_ACCENT), Color32::BLACK);
assert_eq!(ink_on(Color32::from_rgb(0x3f, 0x5f, 0xe0)), Color32::WHITE);
assert_eq!(ink_on(Color32::from_rgb(0xb7, 0x02, 0x28)), Color32::WHITE);
// The accent underlines "What's new" (whose text is the body colour).
let underlined = line_strokes(&output).contains(&fill);
assert!(underlined, "the link's underline takes the accent");
assert!((contrast(Color32::BLACK, Color32::WHITE) - 21.0).abs() < 0.01);
}
#[test]
fn hovering_later_uses_the_prompts_own_background_not_the_hosts() {
// A host whose light theme hovers in black: LATER's dark text would vanish on it.
let ctx = Context::default();
ctx.set_theme(egui::ThemePreference::Light);
ctx.global_style_mut(|style| {
style.animation_time = 0.0;
style.visuals.widgets.hovered.weak_bg_fill = Color32::BLACK;
style.visuals.widgets.hovered.bg_fill = Color32::BLACK;
});
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let later = text_pos(&output, "LATER").expect("LATER is painted");
frame(&ctx, &mut prompt, vec![Event::PointerMoved(later)]);
let (_, hovered) = frame(&ctx, &mut prompt, vec![Event::PointerMoved(later)]);
let fills = rect_fills(&hovered);
assert!(fills.iter().any(|(r, f)| *f == button_states(false).0 && r.contains(later)), "LATER hovers in the prompt's grey: {fills:?}");
assert!(!fills.iter().any(|(r, f)| *f == Color32::BLACK && r.contains(later)), "not in the host's black");
}
/// The strokes of the rectangles painted this frame, with each rectangle.
fn rect_strokes(output: &FullOutput) -> Vec<(Rect, egui::Stroke)> {
fn collect(shape: &egui::Shape, out: &mut Vec<(Rect, egui::Stroke)>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)),
egui::Shape::Rect(rect) => out.push((rect.rect, rect.stroke)),
_ => {}
}
}
let mut strokes = Vec::new();
for clipped in &output.shapes {
collect(&clipped.shape, &mut strokes);
}
strokes
}
#[test]
fn a_pressed_or_focused_button_shows_a_ring() {
// Focus and a press both draw the active state; its 2 px ring must show on LATER (whose
// border used to be fixed) and on the filled DOWNLOAD.
for label in ["LATER", "DOWNLOAD"] {
let ctx = dark_context();
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let at = text_pos(&output, label).expect("the button is painted");
let down = Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE };
frame(&ctx, &mut prompt, vec![Event::PointerMoved(at)]);
let (_, pressed) = frame(&ctx, &mut prompt, vec![down]);
let ring = rect_strokes(&pressed).iter().any(|(r, s)| r.contains(at) && s.width == 2.0 && s.color == Color32::from_rgb(245, 245, 248));
assert!(ring, "{label} shows the focus ring while pressed");
}
}
#[test]
fn a_focused_whats_new_link_shows_a_ring() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let link = text_pos(&output, "What's new").expect("the link is painted");
let tab = Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
let ringed = (0..8).any(|_| {
let (_, out) = frame(&ctx, &mut prompt, vec![tab.clone()]);
rect_strokes(&out).iter().any(|(r, s)| r.contains(link) && s.width == 2.0 && s.color == Color32::from_rgb(245, 245, 248))
});
assert!(ringed, "tabbing to the link shows the focus ring");
}
#[test]
fn whats_new_lines_up_with_the_buttons() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let link = text_pos(&output, "What's new").expect("the link is painted");
let button = text_pos(&output, "DOWNLOAD").expect("the button is painted");
assert!((link.y - button.y).abs() <= 1.0, "link centred at {} but the button at {}", link.y, button.y);
}
#[test]
fn clicking_download_answers_download() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let at = text_pos(&output, "DOWNLOAD").expect("DOWNLOAD is painted");
let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
frame(&ctx, &mut prompt, vec![Event::PointerMoved(at)]);
frame(&ctx, &mut prompt, vec![button(true)]);
let (response, _) = frame(&ctx, &mut prompt, vec![button(false)]);
assert_eq!(response, StartupPromptResponse::Download);
}
/// The colours of the line segments painted this frame.
fn line_strokes(output: &FullOutput) -> Vec<Color32> {
fn collect(shape: &egui::Shape, out: &mut Vec<Color32>) {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, out)),
egui::Shape::LineSegment { stroke, .. } => out.push(stroke.color),
_ => {}
}
}
let mut colors = Vec::new();
for clipped in &output.shapes {
collect(&clipped.shape, &mut colors);
}
colors
}
#[test]
fn every_craft_accent_reaches_aa_for_its_labels() {
// The 13 apps' accents (as ArtCraft Launcher shows them, PdfCraft's new red) and the default.
let accents = [
0x2f7bf5, 0xe8573f, 0x8b5cf6, 0xf2a516, 0xb70228, 0xe0368f, 0x7bb51c, 0x02da96, 0x3f5fe0, 0x1fa5c9, 0xf07a1e, 0x3ea6e8, 0x1f9d55,
0x3478f6,
];
for hex in accents {
let fill = Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8);
let ratio = contrast(fill, ink_on(fill));
assert!(ratio >= 4.5, "#{hex:06x}: {ratio:.2}:1");
}
}
/// Where the frame painted `text` (inside its galley, so a pointer there is over it).
fn text_pos(output: &FullOutput, text: &str) -> Option<egui::Pos2> {
fn find(shape: &egui::Shape, text: &str) -> Option<egui::Pos2> {
match shape {
egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| find(s, text)),
egui::Shape::Text(t) if t.galley.text() == text => Some(t.pos + t.galley.rect.center().to_vec2()),
_ => None,
}
}
output.shapes.iter().find_map(|c| find(&c.shape, text))
}
#[test]
fn the_prompt_names_the_developer() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
assert!(painted_text(&output).lines().any(|l| l == "by ArtCraft"), "every suite app is ArtCraft's by default");
// An app can name its own developer.
static OTHER: std::sync::OnceLock<App> = std::sync::OnceLock::new();
let other: &'static App = OTHER.get_or_init(|| App { developer: "Someone Else", ..PHOTOCRAFT });
let ctx = dark_context();
let mut prompt = StartupPrompt { app: other, ..prompt_for(false) };
frame(&ctx, &mut prompt, Vec::new());
let (_, output) = frame(&ctx, &mut prompt, Vec::new());
let painted = painted_text(&output);
assert!(painted.lines().any(|l| l == "by Someone Else") && !painted.contains("by ArtCraft"), "{painted}");
}
#[test]
fn creates_startup_prompt_dialog() {
let release = Release { version: crate::version::parse("0.2.0"), tag: "v0.2.0".to_string(), ..Release::default() };
let prompt = StartupPrompt::new(&PHOTOCRAFT, release, "0.1.0");
assert_eq!(prompt.app.name, "PhotoCraft");
assert_eq!(prompt.running_version, "0.1.0");
assert!(prompt.open);
}
#[test]
fn download_prompt_paints_card_actions_and_stays_pending() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
// A new Area first measures itself invisibly; inspect the following visible frame.
let (sizing, _) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(sizing, StartupPromptResponse::Pending);
let (response, output) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(response, StartupPromptResponse::Pending);
assert!(prompt.open);
let painted = painted_text(&output);
for expected in [
"PhotoCraft",
"UPDATE",
"v0.1.0 to 0.2.0",
"PhotoCraft 0.2.0 is available with the latest features and bug fixes.",
"What's new",
"DOWNLOAD",
"LATER",
] {
assert!(painted.contains(expected), "missing {expected:?} in painted text {painted:?}");
}
assert!(!painted.contains("UPDATE AND RESTART"), "download branch must not offer the self-update button: {painted:?}");
// No glyph that a font might lack: apps that panic on a missing glyph would take the prompt down (#343).
assert!(painted.is_ascii(), "the prompt paints a non-ASCII character: {painted:?}");
let fills = rect_fills(&output);
let backdrop = fills.iter().any(|(rect, fill)| *fill == Color32::from_black_alpha(150) && rect.size() == screen().size());
assert!(backdrop, "modal backdrop missing: {fills:?}");
let card = fills.iter().any(|(_, fill)| *fill == DARK_CARD);
assert!(card, "dialog card missing: {fills:?}");
}
#[test]
fn self_updating_prompt_offers_update_and_restart() {
let ctx = dark_context();
let mut prompt = prompt_for(true);
// A new Area first measures itself invisibly; inspect the following visible frame.
let (sizing, _) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(sizing, StartupPromptResponse::Pending);
let (response, output) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(response, StartupPromptResponse::Pending);
assert!(prompt.open);
let painted = painted_text(&output);
assert!(painted.contains("UPDATE AND RESTART"), "self-update button missing: {painted:?}");
assert!(painted.contains("LATER"), "dismiss button missing: {painted:?}");
assert!(!painted.contains("DOWNLOAD"), "self-updating copy must not offer the manual download button: {painted:?}");
let card = rect_fills(&output).iter().any(|(_, fill)| *fill == DARK_CARD);
assert!(card, "dialog card missing");
}
#[test]
fn installing_prompt_paints_status_and_dismiss() {
let ctx = dark_context();
let mut prompt = prompt_for(true);
prompt.is_installing = true;
prompt.status_message = Some("Couldn't reach GitHub".to_string());
// A new Area first measures itself invisibly; inspect the following visible frame.
let (sizing, _) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(sizing, StartupPromptResponse::Pending);
let (response, output) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(response, StartupPromptResponse::Pending);
assert!(prompt.open);
let painted = painted_text(&output);
for expected in ["PhotoCraft", "Downloading and installing update…", "Couldn't reach GitHub", "DISMISS", "What's new"] {
assert!(painted.contains(expected), "missing {expected:?} in painted text {painted:?}");
}
for absent in ["LATER", "DOWNLOAD", "UPDATE AND RESTART"] {
assert!(!painted.contains(absent), "unexpected {absent:?} while installing: {painted:?}");
}
let fills = rect_fills(&output);
let alert = fills.iter().any(|(_, fill)| *fill == DARK_STATUS);
assert!(alert, "status alert frame missing: {fills:?}");
}
#[test]
fn escape_dismisses_open_prompt() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
let (first, _) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(first, StartupPromptResponse::Pending);
assert!(prompt.open);
let escape = Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
let (dismissed, dismissed_output) = frame(&ctx, &mut prompt, vec![escape]);
assert_eq!(dismissed, StartupPromptResponse::Later);
assert!(!prompt.open);
// The dismissing frame still paints the dialog; the closed state applies from the next frame on.
assert!(!painted_text(&dismissed_output).is_empty(), "dismissing frame should have painted the dialog");
let (later, closed_output) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(later, StartupPromptResponse::Later);
assert!(closed_output.shapes.is_empty(), "closed prompt painted {} shapes", closed_output.shapes.len());
}
#[test]
fn closed_prompt_returns_later_without_painting() {
let ctx = dark_context();
let mut prompt = prompt_for(false);
prompt.open = false;
let (response, output) = frame(&ctx, &mut prompt, Vec::new());
assert_eq!(response, StartupPromptResponse::Later);
assert!(!prompt.open);
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<Event>| {
// 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);
}
}