W16 custom backgrounds: core + render layer (Settings UI pending)
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 <noreply@anthropic.com>
This commit is contained in:
+138
-3
@@ -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<Vec<u8>>),
|
||||
/// 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<Vec<u8>>),
|
||||
/// 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<AudioDevice>,
|
||||
selected_output: Option<AudioDevice>,
|
||||
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<bytes::Bytes>,
|
||||
peers: HashMap<EndpointId, PeerState>,
|
||||
peer_volumes: HashMap<EndpointId, f32>,
|
||||
audio_levels: HashMap<EndpointId, f32>,
|
||||
@@ -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<bytes::Bytes> {
|
||||
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> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user