From 70a0e6798fa87e71d4425797ae9f0bd2192262d4 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 20 Jun 2026 17:29:09 -0400 Subject: [PATCH] W16 custom backgrounds: core + render layer (Settings UI pending) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure src/background.rs (process_background downscale→PNG, scrim_color; 5 tests), AppConfig.background/background_dim + background_path(), cached AppState.background_image, PickBackgroundFile/BackgroundFilePicked/RemoveBackground/SetBackgroundDim handlers, view_with_background stack(image Cover→scrim→ui) + transparent screen roots. Lib builds clean. Remaining: Settings UI controls + full build/clippy/test pass. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++- src/background.rs | 107 +++++++++++++++++++++++++++++++++++ src/config.rs | 27 +++++++++ src/lib.rs | 1 + 4 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 src/background.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 52e75e1..6397cfc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -290,6 +290,15 @@ pub enum AppMessage { /// Result of the avatar file picker: the chosen file's raw bytes, or `None` /// if the user cancelled. AvatarFilePicked(Option>), + /// Open the native file picker to choose a custom UI background image (W16). + PickBackgroundFile, + /// Result of the background file picker: the chosen file's raw bytes, or + /// `None` if the user cancelled. + BackgroundFilePicked(Option>), + /// Clear the custom background, reverting to the theme background (W16). + RemoveBackground, + /// Set the background legibility scrim strength (0.0..=1.0) (W16). + SetBackgroundDim(f32), /// Toggle the Chat drawer open/closed (drawer layout). ToggleDrawerChat, /// Start/stop sharing our own screen (spawns/kills a pixelpass host). @@ -335,6 +344,10 @@ pub struct AppState { selected_input: Option, selected_output: Option, config: AppConfig, + /// Decoded bytes of the custom background image (W16), cached so `view()` + /// doesn't read the file from disk on every redraw. Loaded on startup and + /// refreshed when the background is changed/removed. `None` = no custom bg. + background_image: Option, peers: HashMap, peer_volumes: HashMap, audio_levels: HashMap, @@ -470,6 +483,7 @@ impl Default for AppState { let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned(); let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned(); + let background_image = load_background_bytes(&config); Self { // Pre-fill the nickname with the last one used (or "Peer" by default). @@ -489,6 +503,7 @@ impl Default for AppState { selected_input, selected_output, config, + background_image, peers: HashMap::new(), peer_volumes: HashMap::new(), audio_levels: HashMap::new(), @@ -533,6 +548,15 @@ fn theme(state: &AppState) -> Theme { state.config.theme.base_theme() } +/// Read the custom background PNG (W16) from disk into memory, if one is set and +/// readable. Called once on startup and whenever the background changes, so the +/// per-frame `view()` never touches the filesystem. A missing/unreadable file +/// silently yields `None` (the UI falls back to the theme background). +fn load_background_bytes(config: &AppConfig) -> Option { + let path = config.background.as_deref()?; + std::fs::read(path).ok().map(bytes::Bytes::from) +} + pub fn run_gui() -> iced::Result { // Restore the last window size (saved on close). Position is restored too, // but only on X11 — Wayland's xdg-shell gives clients no way to set their own @@ -540,7 +564,7 @@ pub fn run_gui() -> iced::Result { let saved = AppConfig::load(); let init_size = iced::Size::new(saved.window_width, saved.window_height); let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland()); - iced::application(AppState::default, update, view) + iced::application(AppState::default, update, view_with_background) .title("PeerSpeak P2P Voice Chat") .theme(theme) .subscription(subscription) @@ -1347,6 +1371,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { } } } + AppMessage::PickBackgroundFile => { + // Native picker off the UI thread; result returns as BackgroundFilePicked. + return Task::perform( + async { + let handle = rfd::AsyncFileDialog::new() + .add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"]) + .set_title("Choose a background image") + .pick_file() + .await; + match handle { + Some(h) => Some(h.read().await), + None => None, + } + }, + AppMessage::BackgroundFilePicked, + ); + } + AppMessage::BackgroundFilePicked(picked) => { + if let Some(bytes) = picked { + match crate::background::process_background(&bytes) { + Ok(png) => match AppConfig::background_path() { + Some(path) => { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + match std::fs::write(&path, &png) { + Ok(()) => { + state.config.background = + Some(path.to_string_lossy().into_owned()); + state.config.save(); + // Refresh the in-memory cache from the bytes we + // just wrote (avoids re-reading from disk). + state.background_image = Some(bytes::Bytes::from(png)); + state.status_message = "Background updated.".to_string(); + } + Err(e) => { + state.status_message = + format!("Couldn't save background: {e}"); + } + } + } + None => { + state.status_message = + "Couldn't find a config directory to save the background." + .to_string(); + } + }, + Err(e) => { + state.status_message = e; + } + } + } + } + AppMessage::RemoveBackground => { + // Best-effort delete of our stored copy; clear the config + cache. + if let Some(path) = AppConfig::background_path() { + let _ = std::fs::remove_file(path); + } + state.config.background = None; + state.config.save(); + state.background_image = None; + state.status_message = "Background removed.".to_string(); + } + AppMessage::SetBackgroundDim(dim) => { + state.config.background_dim = dim.clamp(0.0, 1.0); + state.config.save(); + } AppMessage::ToggleDrawerChat => { state.drawer_chat_open = !state.drawer_chat_open; } @@ -2018,6 +2109,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { .into() } +/// Wrap the main [`view`] with the custom background layer (W16). When a +/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the +/// photo sits behind the whole UI with a legibility scrim (the theme base colour +/// at `background_dim` alpha) between them; otherwise return the UI untouched. The +/// three screen roots go transparent (`root_bg` in `view`) so the image shows +/// through the gaps between panels. This is the registered top-level view. +fn view_with_background(state: &AppState) -> Element<'_, AppMessage> { + let content = view(state); + let Some(bytes) = state.background_image.clone() else { + return content; + }; + let pal = state.config.theme.palette(); + let dim = state.config.background_dim; + let image_layer = iced::widget::image(cached_image_handle(bytes)) + .content_fit(iced::ContentFit::Cover) + .width(iced::Length::Fill) + .height(iced::Length::Fill); + let scrim = container( + iced::widget::Space::new() + .width(iced::Length::Fill) + .height(iced::Length::Fill), + ) + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))), + ..Default::default() + }); + iced::widget::stack![image_layer, scrim, content] + .width(iced::Length::Fill) + .height(iced::Length::Fill) + .into() +} + fn view(state: &AppState) -> Element<'_, AppMessage> { // Theme colours — sourced from the active palette (see `src/theme.rs`), so // all styling below re-themes when the user picks a different theme. @@ -2036,6 +2161,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let color_green = pal.green; let color_yellow = pal.yellow; + // The window backdrop fill for the three screen roots. When a custom + // background image is set (W16), the root goes transparent so the image + + // scrim layered behind by `view_with_background` shows through the gaps + // between panels; otherwise it's the usual opaque `crust`. + let root_bg = if state.background_image.is_some() { + Color::TRANSPARENT + } else { + color_crust + }; + // Style Helpers let c_style = move |bg: Color, b_color: Color, radius: f32| { move |_theme: &Theme| container::Style { @@ -2830,7 +2965,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .height(iced::Length::Fill) .padding(24) .center_x(iced::Length::Fill) - .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); + .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); return with_regenerate_confirm(settings_screen.into(), state); } @@ -2889,7 +3024,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ) .width(iced::Length::Fill) .height(iced::Length::Fill) - .style(c_style(color_crust, Color::TRANSPARENT, 0.0)); + .style(c_style(root_bg, Color::TRANSPARENT, 0.0)); with_hotkey_info(with_layout_picker(home.into(), state), state) } else { diff --git a/src/background.rs b/src/background.rs new file mode 100644 index 0000000..d74076a --- /dev/null +++ b/src/background.rs @@ -0,0 +1,107 @@ +//! 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()) +} + +/// 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 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); + } +} diff --git a/src/config.rs b/src/config.rs index e7c8e87..c4c0193 100644 --- a/src/config.rs +++ b/src/config.rs @@ -106,6 +106,10 @@ fn default_true() -> bool { true } +fn default_background_dim() -> f32 { + crate::background::DEFAULT_DIM +} + fn default_volume() -> f32 { 1.0 } @@ -185,6 +189,16 @@ pub struct AppConfig { /// Our chosen avatar (W4): monogram fallback or a bundled preset. #[serde(default)] pub avatar: crate::avatar::Avatar, + /// Custom UI background image (W16): path to the downscaled PNG we wrote into + /// the config dir (see `background_path`). `None` = use the theme background. + /// Local-only; never sent to peers. + #[serde(default)] + pub background: Option, + /// Scrim strength drawn over the custom background for legibility (0.0 = image + /// at full strength, 1.0 = fully hidden behind the theme base). See + /// `crate::background::scrim_color`. + #[serde(default = "default_background_dim")] + pub background_dim: f32, /// What a call recording captures (mixed / per-peer stems / both). #[serde(default)] pub recording_mode: RecordingMode, @@ -280,6 +294,8 @@ impl Default for AppConfig { room_layout: RoomLayout::default(), theme: AppTheme::default(), avatar: crate::avatar::Avatar::default(), + background: None, + background_dim: default_background_dim(), recording_mode: RecordingMode::default(), custom_sound_self_join: None, custom_sound_peer_join: None, @@ -348,6 +364,17 @@ impl AppConfig { }) } + /// Path the processed custom-background PNG (W16) is written to, alongside + /// `config.json` in the app config dir. We store our own downscaled copy here + /// (rather than base64 in the config) so the JSON stays small. + pub fn background_path() -> Option { + dirs::config_dir().map(|mut p| { + p.push("peerspeak"); + p.push("background.png"); + p + }) + } + pub fn load() -> Self { if let Some(path) = Self::config_path() && let Ok(contents) = fs::read_to_string(&path) diff --git a/src/lib.rs b/src/lib.rs index b110029..1dd59ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod notify; pub mod screenshare; pub mod sanitize; pub mod avatar; +pub mod background; pub mod recents; pub mod discovery; pub mod hotkeys;