Files
funman300 3eb3a26789 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>
2026-05-08 11:07:31 -07:00

115 lines
3.6 KiB
Rust

//! 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}");
}