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`
|
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
|
||||||
/// if the user cancelled.
|
/// if the user cancelled.
|
||||||
AvatarFilePicked(Option<Vec<u8>>),
|
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).
|
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||||
ToggleDrawerChat,
|
ToggleDrawerChat,
|
||||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||||
@@ -335,6 +344,10 @@ pub struct AppState {
|
|||||||
selected_input: Option<AudioDevice>,
|
selected_input: Option<AudioDevice>,
|
||||||
selected_output: Option<AudioDevice>,
|
selected_output: Option<AudioDevice>,
|
||||||
config: AppConfig,
|
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>,
|
peers: HashMap<EndpointId, PeerState>,
|
||||||
peer_volumes: HashMap<EndpointId, f32>,
|
peer_volumes: HashMap<EndpointId, f32>,
|
||||||
audio_levels: 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_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 selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
||||||
|
|
||||||
|
let background_image = load_background_bytes(&config);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||||
@@ -489,6 +503,7 @@ impl Default for AppState {
|
|||||||
selected_input,
|
selected_input,
|
||||||
selected_output,
|
selected_output,
|
||||||
config,
|
config,
|
||||||
|
background_image,
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
peer_volumes: HashMap::new(),
|
peer_volumes: HashMap::new(),
|
||||||
audio_levels: HashMap::new(),
|
audio_levels: HashMap::new(),
|
||||||
@@ -533,6 +548,15 @@ fn theme(state: &AppState) -> Theme {
|
|||||||
state.config.theme.base_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 {
|
pub fn run_gui() -> iced::Result {
|
||||||
// Restore the last window size (saved on close). Position is restored too,
|
// 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
|
// 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 saved = AppConfig::load();
|
||||||
let init_size = iced::Size::new(saved.window_width, saved.window_height);
|
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());
|
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")
|
.title("PeerSpeak P2P Voice Chat")
|
||||||
.theme(theme)
|
.theme(theme)
|
||||||
.subscription(subscription)
|
.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 => {
|
AppMessage::ToggleDrawerChat => {
|
||||||
state.drawer_chat_open = !state.drawer_chat_open;
|
state.drawer_chat_open = !state.drawer_chat_open;
|
||||||
}
|
}
|
||||||
@@ -2018,6 +2109,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.into()
|
.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> {
|
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||||
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
|
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
|
||||||
// all styling below re-themes when the user picks a different theme.
|
// 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_green = pal.green;
|
||||||
let color_yellow = pal.yellow;
|
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
|
// Style Helpers
|
||||||
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
let c_style = move |bg: Color, b_color: Color, radius: f32| {
|
||||||
move |_theme: &Theme| container::Style {
|
move |_theme: &Theme| container::Style {
|
||||||
@@ -2830,7 +2965,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
.height(iced::Length::Fill)
|
.height(iced::Length::Fill)
|
||||||
.padding(24)
|
.padding(24)
|
||||||
.center_x(iced::Length::Fill)
|
.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);
|
return with_regenerate_confirm(settings_screen.into(), state);
|
||||||
}
|
}
|
||||||
@@ -2889,7 +3024,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
)
|
)
|
||||||
.width(iced::Length::Fill)
|
.width(iced::Length::Fill)
|
||||||
.height(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)
|
with_hotkey_info(with_layout_picker(home.into(), state), state)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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<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())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,10 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_background_dim() -> f32 {
|
||||||
|
crate::background::DEFAULT_DIM
|
||||||
|
}
|
||||||
|
|
||||||
fn default_volume() -> f32 {
|
fn default_volume() -> f32 {
|
||||||
1.0
|
1.0
|
||||||
}
|
}
|
||||||
@@ -185,6 +189,16 @@ pub struct AppConfig {
|
|||||||
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub avatar: crate::avatar::Avatar,
|
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<String>,
|
||||||
|
/// 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).
|
/// What a call recording captures (mixed / per-peer stems / both).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recording_mode: RecordingMode,
|
pub recording_mode: RecordingMode,
|
||||||
@@ -280,6 +294,8 @@ impl Default for AppConfig {
|
|||||||
room_layout: RoomLayout::default(),
|
room_layout: RoomLayout::default(),
|
||||||
theme: AppTheme::default(),
|
theme: AppTheme::default(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
background: None,
|
||||||
|
background_dim: default_background_dim(),
|
||||||
recording_mode: RecordingMode::default(),
|
recording_mode: RecordingMode::default(),
|
||||||
custom_sound_self_join: None,
|
custom_sound_self_join: None,
|
||||||
custom_sound_peer_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<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut p| {
|
||||||
|
p.push("peerspeak");
|
||||||
|
p.push("background.png");
|
||||||
|
p
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
if let Some(path) = Self::config_path()
|
if let Some(path) = Self::config_path()
|
||||||
&& let Ok(contents) = fs::read_to_string(&path)
|
&& let Ok(contents) = fs::read_to_string(&path)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ pub mod notify;
|
|||||||
pub mod screenshare;
|
pub mod screenshare;
|
||||||
pub mod sanitize;
|
pub mod sanitize;
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
|
pub mod background;
|
||||||
pub mod recents;
|
pub mod recents;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod hotkeys;
|
||||||
|
|||||||
Reference in New Issue
Block a user