//! Custom UI background (W16): turn a user-picked image into a capped PNG to //! render behind the whole UI, plus the legibility scrim drawn over it. //! //! This is the pure, unit-testable seam — `process_background` decodes/downscales //! arbitrary input defensively (same caution as avatar uploads) and `scrim_color` //! computes the overlay tint. The file I/O, the `rfd` picker, and the iced //! `stack!` that layers image → scrim → UI all live at the app edge in //! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so //! there's no gossip-frame budget here (hence a much larger size cap than avatars). use iced::Color; /// Longest side a custom background is downscaled to on ingest (aspect preserved, /// never upscaled). Big enough to look crisp filling the window, small enough to /// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap. pub const BACKGROUND_MAX_PX: u32 = 1920; /// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's /// fully hidden behind the theme's base colour. Half keeps a photo clearly visible /// while text and cards stay readable over it. pub const DEFAULT_DIM: f32 = 0.5; /// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is /// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is, /// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is /// bounded by the `image` crate's defaults so a malformed/huge file is rejected /// rather than exhausting memory. Errors come back as a message for the UI. pub fn process_background(raw: &[u8]) -> Result, String> { let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?; // Only ever shrink. `resize` preserves aspect, fitting within the box; a // higher-quality filter than `thumbnail` since a background fills the window. let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX { img.resize( BACKGROUND_MAX_PX, BACKGROUND_MAX_PX, image::imageops::FilterType::Lanczos3, ) } else { img }; let mut png = std::io::Cursor::new(Vec::new()); scaled .write_to(&mut png, image::ImageFormat::Png) .map_err(|e| format!("Couldn't encode image: {e}"))?; Ok(png.into_inner()) } /// A filesystem-safe, app-owned filename for the processed PNG of a per-game /// background (W18), derived from the game's stable id by hashing rather than /// embedding the raw id: keeps the name short and safe (ids contain `:` and /// arbitrary executable basenames) and avoids leaking the id into the filesystem. /// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always /// maps to the same file. pub fn game_background_filename(game_id: &str) -> String { // FNV-1a, 64-bit. let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for b in game_id.as_bytes() { hash ^= *b as u64; hash = hash.wrapping_mul(0x0000_0100_0000_01b3); } format!("game-bg-{hash:016x}.png") } /// The legibility scrim drawn between the background image and the UI: the active /// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim` /// recedes the image so body text and panel chrome stay readable, and it re-tints /// per theme since `base` comes from the active palette. pub fn scrim_color(base: Color, dim: f32) -> Color { Color { a: dim.clamp(0.0, 1.0), ..base } } #[cfg(test)] mod tests { use super::*; /// A valid PNG of the given size, as raw bytes (test helper). fn make_png(w: u32, h: u32) -> Vec { let img = image::DynamicImage::new_rgb8(w, h); let mut buf = std::io::Cursor::new(Vec::new()); img.write_to(&mut buf, image::ImageFormat::Png).unwrap(); buf.into_inner() } #[test] fn process_background_downscales_oversized() { // A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX, // aspect preserved, and the result re-decodes as a PNG within bounds. let raw = make_png(4000, 2000); let png = process_background(&raw).expect("should process"); let decoded = image::load_from_memory(&png).unwrap(); assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX); assert_eq!(decoded.width(), BACKGROUND_MAX_PX); assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept } #[test] fn process_background_leaves_small_images_unscaled() { let raw = make_png(640, 480); let png = process_background(&raw).expect("should process"); let decoded = image::load_from_memory(&png).unwrap(); assert_eq!((decoded.width(), decoded.height()), (640, 480)); } #[test] fn process_background_rejects_non_image() { assert!(process_background(b"definitely not an image").is_err()); } #[test] fn game_background_filename_is_stable_safe_and_distinct() { let a = game_background_filename("steam:730"); // Stable for the same id. assert_eq!(a, game_background_filename("steam:730")); // Distinct ids → distinct files (no `:` or path chars leak through). assert_ne!(a, game_background_filename("exe:hl2_linux")); assert!(a.starts_with("game-bg-") && a.ends_with(".png")); assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\')); } #[test] fn scrim_color_sets_alpha_and_keeps_rgb() { let base = Color::from_rgb(0.1, 0.2, 0.3); let s = scrim_color(base, 0.5); assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3)); assert!((s.a - 0.5).abs() < f32::EPSILON); } #[test] fn scrim_color_clamps_dim() { let base = Color::BLACK; assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON); assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON); } }