feat(app): wire desktop window icon — Terminal ▌RS mark at runtime

Closes Resume-prompt Option A (the post-v0.21.0 first option).
Half-day desktop work, no cert dependency.

Three deliverables:

1. **SVG-authored icon** (`solitaire_engine/src/assets/icon_svg.rs`)
   — square Terminal mark: `#151515` background, brick-red
   `#a54242` 1 px border, brick-red ▌ cursor block centered, "RS"
   monogram in `#d0d0d0` foreground gray beneath. Same shape that
   already lives on the splash boot screen and card-back monogram,
   reused as the project's signature visual mark. Authored in a
   64-unit logical box so it scales cleanly at every rasterisation
   target.

2. **9-size PNG hierarchy** (16, 24, 32, 48, 64, 128, 256, 512, 1024
   px) regenerated by `solitaire_engine/examples/icon_generator.rs`
   into `assets/icon/icon_<size>.png`. Sizes cover Linux hicolor
   (16, 24, 32, 48, 64, 128, 256, 512), Windows .ico targets (16,
   32, 48, 256), and macOS .icns targets (16, 32, 64, 128, 256,
   512, 1024). The runtime path uses just the 256 px slot; the
   smaller sizes are pre-rendered for downstream packaging.

3. **Runtime `Window::icon` wiring** (`solitaire_app/src/lib.rs`).
   Bevy 0.18 has no `Window::icon` field — the icon is set through
   the underlying `winit::window::Window` via the `WinitWindows`
   resource. `set_window_icon` runs each Update tick, retries
   silently until `WinitWindows` is populated (typically frame 1
   or 2), decodes the embedded 256 px PNG via `tiny_skia`, builds
   a `winit::window::Icon`, and self-disables via `Local<bool>`.
   Same one-shot pattern as `apply_smart_default_window_size`.
   Desktop-only — Android draws its launcher icon from the APK
   manifest, so the system is target-gated to
   `cfg(not(target_os = "android"))`.

Dep changes (CLAUDE.md §8 user-confirmed):

- `winit = "0.30"` promoted from a transitive Bevy dep to a direct
  dep on `solitaire_app` so `winit::window::Icon` is in scope —
  bevy_winit 0.18 doesn't re-export it. Version pinned to whatever
  Bevy uses; if Bevy bumps winit, this line bumps in lockstep.
- `tiny-skia` added as a direct dep on `solitaire_app` for PNG →
  RGBA decode. Already in workspace deps for `solitaire_engine`;
  no version drift risk.
- Both new deps target-gated to non-Android only.

Test infrastructure: `solitaire_engine/tests/icon_svg_pin.rs`
hashes the rasterised RGBA bytes at all 9 sizes via FNV-1a (same
shape as `card_face_svg_pin`). Bootstrap pattern (empty EXPECTED
→ panic with hashes formatted as Rust source → paste back in)
handles future intentional builder edits cleanly.

Workspace clippy + cargo test --workspace clean. 1185 passing
(+1 from v0.21.0's 1184 baseline — the icon pin's
`rasterised_icon_bytes_match_pinned_hashes`).

Out of scope for this commit: `.icns` / `.ico` bundling for
macOS / Windows app packaging. Both are packaging-time concerns
(set via bundle manifests, not runtime calls) and would need new
deps (`ico` and `icns` crates) — separate followup if/when the
project ships as a packaged macOS / Windows app rather than just
`cargo run`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-08 11:07:31 -07:00
parent 0c1cc40266
commit 3eb3a26789
16 changed files with 347 additions and 9 deletions
Generated
+2
View File
@@ -6957,6 +6957,8 @@ dependencies = [
"keyring",
"solitaire_data",
"solitaire_engine",
"tiny-skia 0.12.0",
"winit",
]
[[package]]
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 927 B

+17 -8
View File
@@ -22,16 +22,25 @@ bevy = { workspace = true }
solitaire_engine = { workspace = true }
solitaire_data = { workspace = true }
# `keyring`'s default-store init only matters on platforms with a
# real keychain backend (Linux Secret Service, macOS Keychain,
# Windows Credential Store). The crate also pulls `rpassword`
# transitively, which uses `libc::__errno_location` — a symbol
# Android's bionic doesn't expose. Target-gating keeps
# `cargo apk build` viable; the call site in `lib.rs` has its own
# `cfg(not(target_os = "android"))` guard so the desktop init path
# is unchanged.
# Desktop-only deps. `keyring`'s default-store init only matters on
# platforms with a real keychain backend (Linux Secret Service,
# macOS Keychain, Windows Credential Store), and its transitive
# `rpassword` uses `libc::__errno_location` — a symbol Android's
# bionic doesn't expose. `winit` is promoted from a transitive
# Bevy 0.18 → bevy_winit 0.18 → winit 0.30 dep to a direct dep so
# the `Window::icon` wiring in `set_window_icon` can construct
# `winit::window::Icon` values (bevy_winit 0.18 doesn't re-export
# `Icon`). Android draws its launcher icon from the APK manifest,
# so neither dep matters there. Target-gating keeps `cargo apk
# build` viable; the desktop call sites have their own
# `cfg(not(target_os = "android"))` guards.
[target.'cfg(not(target_os = "android"))'.dependencies]
keyring = { workspace = true }
winit = { version = "0.30", default-features = false }
# `tiny-skia` is already in the workspace deps for `solitaire_engine`;
# `solitaire_app` consumes it directly only on the desktop icon path
# (PNG → raw RGBA decode for `set_window_icon`).
tiny-skia = { workspace = true }
# --- Android packaging metadata (read by `cargo-apk`) -------------------
#
+75
View File
@@ -21,6 +21,8 @@ use bevy::prelude::*;
use bevy::window::{
Monitor, MonitorSelection, PresentMode, PrimaryMonitor, PrimaryWindow, WindowPosition,
};
#[cfg(not(target_os = "android"))]
use bevy::winit::WinitWindows;
use solitaire_data::{load_settings_from, provider_for_backend, settings_file_path, Settings};
use solitaire_engine::{
register_theme_asset_sources, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin,
@@ -174,6 +176,14 @@ pub fn run() {
.add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin);
// Wire the runtime window icon. Bevy 0.18 has no first-class
// `Window::icon` field; the icon is set through the underlying
// `winit::window::Window` via `WinitWindows`. Android draws its
// launcher icon from the APK manifest, so the system is desktop-
// only — same target-gate as the `winit` dep itself.
#[cfg(not(target_os = "android"))]
app.add_systems(Update, set_window_icon);
// Smart default window sizing: when no saved geometry was loaded,
// resize the freshly-opened 1280×800 window to ~70 % of the primary
// monitor's logical size on the first frame. Without this, a 4K
@@ -251,6 +261,71 @@ fn apply_smart_default_window_size(
*applied = true;
}
/// One-shot Update system that sets the primary window's taskbar /
/// title-bar icon to the embedded 256 px Terminal-aesthetic mark
/// generated by `solitaire_engine/examples/icon_generator.rs`.
///
/// Bevy 0.18 has no `Window::icon` field — the icon is set through
/// the underlying `winit::window::Window` via the `WinitWindows`
/// resource. The system is desktop-only (Android draws its launcher
/// icon from the APK manifest, not from any runtime call). Returns
/// silently and tries again next frame until both the primary
/// window and `WinitWindows` are populated, then sets the icon
/// once and self-disables via `Local<bool>`.
///
/// Icon bytes are `include_bytes!()`-embedded at compile time, same
/// shape as the audio assets and default-theme SVGs — no runtime
/// asset-path resolution, no `cargo run` working-directory
/// assumptions. PNG → RGBA decode runs through `tiny_skia` (already
/// in the build for SVG rasterisation), so this system adds zero
/// new dependencies on top of the direct `winit` dep that's
/// already required for `Icon` construction.
#[cfg(not(target_os = "android"))]
fn set_window_icon(
mut applied: Local<bool>,
primary_window: Query<Entity, With<PrimaryWindow>>,
winit_windows: NonSend<WinitWindows>,
) {
if *applied {
return;
}
let Ok(primary_entity) = primary_window.single() else {
return;
};
let Some(window_wrapper) = winit_windows.get_window(primary_entity) else {
// Primary window's underlying winit handle not yet
// populated — `WinitWindows` fills in after the first
// `Resumed` event. Try again next frame.
return;
};
// The 256 × 256 PNG is sufficient for `set_window_icon`; winit
// scales it for the actual rendered size. Smaller PNGs in
// `assets/icon/` exist for downstream Linux hicolor / Windows
// `.ico` / macOS `.icns` packaging — they're not used here.
const ICON_BYTES: &[u8] = include_bytes!("../../assets/icon/icon_256.png");
let pixmap = match tiny_skia::Pixmap::decode_png(ICON_BYTES) {
Ok(p) => p,
Err(e) => {
eprintln!("warn: could not decode embedded window icon PNG: {e}");
*applied = true; // don't retry every frame
return;
}
};
let rgba = pixmap.data().to_vec();
let icon = match winit::window::Icon::from_rgba(rgba, pixmap.width(), pixmap.height()) {
Ok(i) => i,
Err(e) => {
eprintln!("warn: could not construct window icon: {e}");
*applied = true;
return;
}
};
window_wrapper.set_window_icon(Some(icon));
*applied = true;
}
/// Wraps the default panic hook with one that also appends a crash log
/// to `<data_dir>/crash.log` (next to `settings.json`). The default hook
/// still runs afterwards, so stderr output and debugger integration are
@@ -0,0 +1,67 @@
//! Application-icon generator — rasterises the project's icon SVG
//! into `assets/icon/icon_<size>.png` at every size in
//! `card_face_svg::ICON_SIZES` (16, 24, 32, 48, 64, 128, 256, 512,
//! 1024). Sufficient to cover Linux hicolor, Windows `.ico`, and
//! macOS `.icns` packaging targets — and the runtime `Window::icon`
//! wiring picks the 256 px slot.
//!
//! Run with:
//!
//! ```sh
//! cargo run --example icon_generator --release
//! ```
//!
//! Same shape as `card_face_generator`: SVG builder lives in
//! `solitaire_engine::assets::icon_svg` so the `icon_svg_pin`
//! integration test can call it. Rasterisation runs through
//! `assets::rasterize_svg` (the `usvg` + `resvg` + `tiny_skia`
//! pipeline already used by every other generated asset).
use bevy::math::UVec2;
use solitaire_engine::assets::icon_svg::{icon_svg, ICON_SIZES};
use solitaire_engine::assets::rasterize_svg;
use std::path::PathBuf;
use tiny_skia::{IntSize, Pixmap};
fn main() {
let icon_dir = workspace_assets_dir().join("icon");
std::fs::create_dir_all(&icon_dir).expect("create icon dir");
let svg = icon_svg();
for &size in ICON_SIZES {
let target = UVec2::new(size, size);
let pixmap = rasterize_to_pixmap(&svg, target);
let path = icon_dir.join(format!("icon_{size}.png"));
pixmap
.save_png(&path)
.unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
}
println!(
"Wrote {} PNGs ({}{} px) to {}",
ICON_SIZES.len(),
ICON_SIZES.iter().min().copied().unwrap_or(0),
ICON_SIZES.iter().max().copied().unwrap_or(0),
icon_dir.display(),
);
}
fn rasterize_to_pixmap(svg: &str, target: UVec2) -> Pixmap {
let image = rasterize_svg(svg.as_bytes(), target).expect("rasterise icon SVG");
let bytes = image.data.expect("rasterised image carries pixel data");
debug_assert_eq!(
bytes.len(),
(target.x * target.y * 4) as usize,
"rasterised buffer must match width × height × 4 RGBA bytes",
);
let size = IntSize::from_wh(target.x, target.y).expect("non-zero target size");
Pixmap::from_vec(bytes, size).expect("RGBA buffer forms a valid Pixmap")
}
fn workspace_assets_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("solitaire_engine crate has a workspace-root parent")
.join("assets")
}
+70
View File
@@ -0,0 +1,70 @@
//! SVG builder for the Solitaire Quest application icon.
//!
//! Renders the project's signature `▌RS` Terminal mark (the same
//! cursor-block + monogram pair used on the splash boot-screen and
//! card backs) on a dark `#151515` background with a 1 px brick-red
//! border. Square aspect, authored in a 64-unit logical box and
//! scaled at the rasterisation site.
//!
//! Reads at every size from 16 px taskbar tile to 1024 px macOS
//! Retina icon — the high-contrast cursor block carries the
//! recognition load and the smaller `RS` letters sit beneath as
//! a secondary recognition cue.
//!
//! Same SVG-to-PNG pipeline as `card_face_svg` — `icon_generator`
//! example rasterises this at multiple target sizes and writes
//! into `assets/icon/`. The `icon_svg_pin` integration test hashes
//! rasterised RGBA bytes to guard against `usvg`/`resvg` drift.
use bevy::math::UVec2;
/// Default rasterisation target — single canonical size used by the
/// runtime `Window::icon` wiring. The generator example emits
/// additional sizes (16, 32, 48, 64, 128, 256, 512, 1024) for the
/// Linux hicolor hierarchy and for downstream `.ico` / `.icns`
/// packaging.
pub const TARGET: UVec2 = UVec2::new(256, 256);
/// Every size the `icon_generator` example emits. Covers Linux
/// hicolor (16, 24, 32, 48, 64, 128, 256, 512), Windows `.ico`
/// targets (16, 32, 48, 256), and macOS `.icns` targets (16, 32,
/// 64, 128, 256, 512, 1024).
pub const ICON_SIZES: &[u32] = &[16, 24, 32, 48, 64, 128, 256, 512, 1024];
const BG: &str = "#151515"; // BG_BASE
const ACCENT: &str = "#a54242"; // ACCENT_PRIMARY brick red
const FG: &str = "#d0d0d0"; // TEXT_PRIMARY
/// Build the icon SVG. Square aspect, 64 logical units per side.
pub fn icon_svg() -> String {
// Layout in a 64×64 logical box:
// border: 1 logical unit, brick-red, inset 0.5 to
// centre the stroke inside the pixmap.
// corner radius: 6 units (~9 % of side, scales smoothly down
// to 16 px where it disappears into pixel grid).
// `▌` cursor: 18 px tall, 6 px wide, brick-red, centred
// horizontally, sitting on a baseline at y=40
// so there's room for `RS` beneath it.
// `RS` mark: 14 px FiraMono Bold at y=58, foreground gray,
// letter-spaced for readability at small sizes.
//
// The `▌` glyph is U+258C (LEFT HALF BLOCK) — same character the
// splash and card-back monogram use, rendered upright at icon
// scale. FiraMono carries this at usable size (verified by the
// splash + card-back rendering), so `<text>` is safe here unlike
// the suit glyphs.
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect x="0.5" y="0.5" width="63" height="63" rx="6" ry="6"
fill="{BG}" stroke="{ACCENT}" stroke-width="1"/>
<!-- Centred ▌ cursor block at y=22..40, brick-red. -->
<rect x="29" y="22" width="6" height="18" fill="{ACCENT}"/>
<!-- RS monogram beneath, foreground gray. text-anchor=middle so
the letterforms balance around the cursor block above. -->
<text x="32" y="56" font-family="Fira Mono" font-size="14" font-weight="700"
fill="{FG}" text-anchor="middle" letter-spacing="1">RS</text>
</svg>"##
)
}
+1
View File
@@ -7,6 +7,7 @@
//! `AssetSource` implementations for `embedded://` and `themes://`.
pub mod card_face_svg;
pub mod icon_svg;
pub mod sources;
pub mod svg_loader;
pub mod user_dir;
+114
View File
@@ -0,0 +1,114 @@
//! Pinning test for the application-icon SVG builder.
//!
//! Hashes the raw RGBA8 pixel bytes produced by rasterising
//! `icon_svg()` at every size in `ICON_SIZES`, compares each hash
//! to an embedded constant, and fails on any drift. Catches
//! `usvg`/`resvg`/`tiny_skia` rendering changes and any
//! intentional builder edit that wasn't paired with a hash
//! refresh.
//!
//! When the icon SVG changes intentionally (or a dependency
//! upgrade legitimately changes rendering), update `EXPECTED` by
//! emptying it (`&[]`) and re-running this test once — the test
//! will panic with the new hashes formatted as Rust source ready
//! to paste back in. Same bootstrap pattern as
//! `card_face_svg_pin.rs`.
use bevy::math::UVec2;
use solitaire_engine::assets::icon_svg::{icon_svg, ICON_SIZES};
use solitaire_engine::assets::rasterize_svg;
const EXPECTED: &[(u32, u64)] = &[
(16, 0x07e641beea430d66),
(24, 0x24e66767f4756a60),
(32, 0xf22a3104623a3873),
(48, 0x2d7f978cf7b12763),
(64, 0x1b377e3e30202eba),
(128, 0xafdc80f901b45518),
(256, 0x82b5b46f73c5921d),
(512, 0xe14c018e1e285209),
(1024, 0xfcd0a6a3beb68bdb),
];
#[test]
fn rasterised_icon_bytes_match_pinned_hashes() {
let actual = compute_actual_hashes();
if EXPECTED.is_empty() {
panic_with_hashes_to_paste(&actual);
}
assert_eq!(
actual.len(),
EXPECTED.len(),
"icon-size count drifted (actual {} vs expected {})",
actual.len(),
EXPECTED.len(),
);
let mut mismatches: Vec<String> = Vec::new();
for ((actual_size, actual_hash), (expected_size, expected_hash)) in
actual.iter().zip(EXPECTED.iter())
{
assert_eq!(
actual_size, expected_size,
"icon-size order drifted",
);
if actual_hash != expected_hash {
mismatches.push(format!(
" icon_{actual_size}: actual 0x{actual_hash:016x} expected 0x{expected_hash:016x}",
));
}
}
if !mismatches.is_empty() {
let mut msg = String::from(
"rasterised icon bytes drifted from EXPECTED — usvg/resvg/tiny_skia/font upgrade?\n",
);
for m in &mismatches {
msg.push_str(m);
msg.push('\n');
}
msg.push_str(
"\nIf this drift is intentional, replace EXPECTED with `&[]` and re-run\nthis test to print fresh hashes.\n",
);
panic!("{msg}");
}
}
fn compute_actual_hashes() -> Vec<(u32, u64)> {
let svg = icon_svg();
ICON_SIZES
.iter()
.map(|&size| (size, hash_rasterised(&svg, size)))
.collect()
}
fn hash_rasterised(svg: &str, size: u32) -> u64 {
let target = UVec2::new(size, size);
let image = rasterize_svg(svg.as_bytes(), target).expect("rasterise icon SVG");
let bytes = image.data.expect("rasterised image carries RGBA pixel data");
fnv1a(&bytes)
}
/// FNV-1a 64-bit, inline. Same shape as `card_face_svg_pin.rs` —
/// no cryptographic strength needed, just stable byte fingerprints.
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn panic_with_hashes_to_paste(actual: &[(u32, u64)]) -> ! {
let mut out = String::from(
"\nEXPECTED is empty — paste the following into the const literal:\n\nconst EXPECTED: &[(u32, u64)] = &[\n",
);
for (size, hash) in actual {
out.push_str(&format!(" ({size}, 0x{hash:016x}),\n"));
}
out.push_str("];\n");
panic!("{out}");
}