The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
5.9 KiB
Rust
138 lines
5.9 KiB
Rust
//! 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<Vec<u8>, 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<u8> {
|
|
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);
|
|
}
|
|
}
|