feat: add set as wallpaper functionality

Add ability to set current image as desktop wallpaper with keyboard
shortcut 'W' and icon button in Properties panel.

Supports COSMIC, GNOME, KDE, XFCE, and tiling window managers via
automatic detection and fallback mechanism.

Implementation uses wallpaper crate with custom COSMIC config file
integration and gsettings/feh fallbacks.
This commit is contained in:
wfx 2026-01-15 20:37:14 +01:00
parent ca7661fa3e
commit 220a886acc
12 changed files with 399 additions and 24 deletions

View file

@ -110,3 +110,35 @@ impl DocumentContent {
}
}
}
/// Set an image file as desktop wallpaper.
///
/// This function attempts multiple methods in order:
/// 1. COSMIC Desktop (direct config file modification)
/// 2. wallpaper crate (KDE, XFCE, Windows, macOS)
/// 3. gsettings (GNOME)
/// 4. feh (tiling window managers)
///
/// The operation is performed asynchronously and logs success/failure.
pub fn set_as_wallpaper(path: &Path) {
// Canonicalize to absolute path
let abs_path = match path.canonicalize() {
Ok(p) => p,
Err(e) => {
log::error!("Failed to canonicalize path {}: {}", path.display(), e);
return;
}
};
// Convert to string
let path_str = match abs_path.to_str() {
Some(s) => s.to_string(),
None => {
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
return;
}
};
// Delegate to utils with concrete string type
utils::set_as_wallpaper(&path_str);
}

View file

@ -1,2 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// src/app/document/utils.rs
//
// Utility functions for document operations.
/// Set an image as desktop wallpaper using multiple fallback methods.
///
/// Expects an absolute path as string.
pub fn set_as_wallpaper(path_str: &str) {
log::info!("Attempting to set wallpaper: {}", path_str);
// Method 1: Try COSMIC Desktop (direct config file modification)
if let Some(home) = dirs::home_dir() {
let cosmic_config = home.join(".config/cosmic/com.system76.CosmicBackground/v1/all");
if cosmic_config.exists() {
let config_content = format!(
r#"(
output: "all",
source: Path("{}"),
filter_by_theme: true,
rotation_frequency: 300,
filter_method: Lanczos,
scaling_mode: Zoom,
sampling_method: Alphanumeric,
)"#,
path_str
);
match std::fs::write(&cosmic_config, config_content) {
Ok(_) => {
log::info!("✓ Wallpaper set via COSMIC config file");
return;
}
Err(e) => {
log::warn!("Failed to write COSMIC config: {}", e);
}
}
}
}
// Method 2: Try wallpaper crate (supports KDE, XFCE, Windows, macOS)
match wallpaper::set_from_path(path_str) {
Ok(_) => {
log::info!("✓ Wallpaper set successfully via wallpaper crate");
return;
}
Err(e) => {
log::warn!("wallpaper crate failed: {}", e);
}
}
// Method 3: Try GNOME via gsettings
let uri = format!("file://{}", path_str);
log::info!("Trying gsettings with URI: {}", uri);
match std::process::Command::new("gsettings")
.args(&[
"set",
"org.gnome.desktop.background",
"picture-uri",
&uri,
])
.output()
{
Ok(output) if output.status.success() => {
log::info!("✓ Wallpaper set via gsettings (light mode)");
// Also set dark mode wallpaper
let _ = std::process::Command::new("gsettings")
.args(&[
"set",
"org.gnome.desktop.background",
"picture-uri-dark",
&uri,
])
.output();
return;
}
Ok(output) => {
log::warn!(
"gsettings failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Err(e) => {
log::warn!("gsettings command failed: {}", e);
}
}
// Method 4: Try feh (common on tiling WMs like i3, sway)
match std::process::Command::new("feh")
.args(&["--bg-scale", path_str])
.output()
{
Ok(output) if output.status.success() => {
log::info!("✓ Wallpaper set via feh");
return;
}
Ok(_) => {
log::warn!("feh failed");
}
Err(_) => {
log::warn!("feh not available");
}
}
log::error!("✗ All methods failed to set wallpaper");
}

View file

@ -70,6 +70,10 @@ pub enum AppMessage {
#[allow(dead_code)]
RefreshMetadata,
// === Wallpaper ===
/// Set current image as wallpaper.
SetAsWallpaper,
// === Errors ===
/// Display an error message.
#[allow(dead_code)]

View file

@ -244,6 +244,9 @@ fn handle_key_press(key: Key, modifiers: Modifiers) -> Option<AppMessage> {
}
Key::Character(ch) if ch.eq_ignore_ascii_case("n") => Some(ToggleNavBar),
// Wallpaper.
Key::Character(ch) if ch.eq_ignore_ascii_case("w") => Some(SetAsWallpaper),
_ => None,
}
}

View file

@ -104,6 +104,11 @@ pub fn update(model: &mut AppModel, msg: AppMessage) {
refresh_metadata(model);
}
// ===== Wallpaper =================================================================
AppMessage::SetAsWallpaper => {
set_as_wallpaper(model);
}
// ===== Error handling ============================================================
AppMessage::ShowError(msg) => {
model.set_error(msg);
@ -154,3 +159,18 @@ fn current_zoom(model: &AppModel) -> f32 {
fn refresh_metadata(model: &mut AppModel) {
model.metadata = model.document.as_ref().map(|doc| doc.extract_meta());
}
/// Set the current image as desktop wallpaper.
fn set_as_wallpaper(model: &mut AppModel) {
let Some(path) = model.current_path.as_ref() else {
model.set_error("No image loaded");
return;
};
let path = path.clone();
// Spawn async task to set wallpaper
tokio::spawn(async move {
document::set_as_wallpaper(&path);
});
}

View file

@ -52,7 +52,10 @@ pub fn header_start(model: &AppModel) -> Vec<Element<'_, AppMessage>> {
/// Build the right side of the header bar.
pub fn header_end(_model: &AppModel) -> Vec<Element<'_, AppMessage>> {
vec![button::icon(icon::from_name("dialog-information-symbolic"))
.on_press(AppMessage::ToggleContextPage(ContextPage::Properties))
.into()]
vec![
// Info panel toggle
button::icon(icon::from_name("dialog-information-symbolic"))
.on_press(AppMessage::ToggleContextPage(ContextPage::Properties))
.into(),
]
}

View file

@ -3,7 +3,8 @@
//
// Panel content for COSMIC context drawer.
use cosmic::widget::{column, divider, row, text};
use cosmic::iced::Length;
use cosmic::widget::{button, column, divider, horizontal_space, icon, row, text};
use cosmic::Element;
use crate::app::{AppMessage, AppModel};
@ -13,8 +14,8 @@ use crate::fl;
pub fn properties_panel(model: &AppModel) -> Element<'static, AppMessage> {
let mut content = column::with_capacity(16).spacing(8);
// Header.
content = content.push(text::title4(fl!("panel-properties")));
// Header with action icons
content = content.push(panel_header(model));
// Display document metadata if available.
if let Some(ref doc) = model.document {
@ -117,3 +118,30 @@ fn meta_row_small(label: String, value: String) -> Element<'static, AppMessage>
.push(text::caption(value))
.into()
}
/// Panel header with title and action icon buttons.
fn panel_header(model: &AppModel) -> Element<'static, AppMessage> {
let has_doc = model.document.is_some();
row::with_capacity(5)
.spacing(4)
.align_y(cosmic::iced::Alignment::Center)
.push(text::title4(fl!("panel-properties")))
.push(horizontal_space().width(Length::Fill))
.push(
button::icon(icon::from_name("image-x-generic-symbolic"))
.on_press_maybe(has_doc.then_some(AppMessage::SetAsWallpaper))
.tooltip(fl!("action-set-wallpaper"))
)
// .push(
// button::icon(icon::from_name("system-run-symbolic"))
// .on_press_maybe(has_doc.then_some(AppMessage::NoOp)) // TODO: Implement
// .tooltip(fl!("action-open-with"))
// )
// .push(
// button::icon(icon::from_name("system-file-manager-symbolic"))
// .on_press_maybe(has_doc.then_some(AppMessage::NoOp)) // TODO: Implement
// .tooltip(fl!("action-show-in-folder"))
// )
.into()
}