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

160
Cargo.lock generated
View file

@ -295,6 +295,12 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "arrayvec"
version = "0.7.6"
@ -644,7 +650,7 @@ dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"arrayvec 0.7.6",
"log",
"num-rational",
"num-traits",
@ -662,7 +668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"arrayvec 0.7.6",
"log",
"nom",
"num-rational",
@ -675,9 +681,15 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
]
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
version = "0.22.1"
@ -738,6 +750,17 @@ dependencies = [
"core2",
]
[[package]]
name = "blake2b_simd"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587"
dependencies = [
"arrayref",
"arrayvec 0.5.2",
"constant_time_eq",
]
[[package]]
name = "block"
version = "0.1.6"
@ -1150,6 +1173,12 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "constant_time_eq"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -1518,6 +1547,17 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901"
dependencies = [
"libc",
"redox_users 0.3.5",
"winapi",
]
[[package]]
name = "dirs"
version = "5.0.1"
@ -1681,6 +1721,15 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enquote"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06c36cb11dbde389f4096111698d8b567c0720e3452fd5ac3e6b4e47e1939932"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@ -2212,6 +2261,17 @@ dependencies = [
"windows-link",
]
[[package]]
name = "getrandom"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [
"cfg-if",
"libc",
"wasi 0.9.0+wasi-snapshot-preview1",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@ -2220,7 +2280,7 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
]
[[package]]
@ -2927,7 +2987,7 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3e98b1520e49e252237edc238a39869da9f3241f2ec19dc788c1d24694d1e4"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
]
[[package]]
@ -3183,7 +3243,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"smallvec",
]
@ -3193,7 +3253,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"euclid",
"smallvec",
]
@ -3399,7 +3459,7 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e260b6de923e6e47adfedf6243013a7a874684165a6a277594ee3906021b2343"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"euclid",
"num-traits",
]
@ -3527,7 +3587,7 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"wasi 0.11.1+wasi-snapshot-preview1",
"windows-sys 0.61.2",
]
@ -3553,7 +3613,7 @@ version = "22.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bd5a652b6faf21496f2cfd88fc49989c8db0825d1f6746b1a71a6ede24a63ad"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"bit-set",
"bitflags 2.10.0",
"cfg_aliases 0.1.1",
@ -3644,6 +3704,7 @@ dependencies = [
"rust-embed",
"simple_logger",
"tokio",
"wallpaper",
]
[[package]]
@ -4700,7 +4761,7 @@ dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"arrayvec 0.7.6",
"av-scenechange",
"av1-grain",
"bitstream-io",
@ -4788,6 +4849,12 @@ dependencies = [
"font-types",
]
[[package]]
name = "redox_syscall"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
[[package]]
name = "redox_syscall"
version = "0.2.16"
@ -4815,6 +4882,17 @@ dependencies = [
"bitflags 2.10.0",
]
[[package]]
name = "redox_users"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d"
dependencies = [
"getrandom 0.1.16",
"redox_syscall 0.1.57",
"rust-argon2",
]
[[package]]
name = "redox_users"
version = "0.4.6"
@ -4927,7 +5005,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468"
dependencies = [
"base64",
"base64 0.22.1",
"bitflags 2.10.0",
"serde",
"serde_derive",
@ -4940,6 +5018,18 @@ version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rust-argon2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb"
dependencies = [
"base64 0.13.1",
"blake2b_simd",
"constant_time_eq",
"crossbeam-utils",
]
[[package]]
name = "rust-embed"
version = "8.9.0"
@ -4974,6 +5064,12 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rust-ini"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac66e816614e124a692b6ac1b8437237a518c9155a3aacab83a373982630c715"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@ -5500,7 +5596,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"grid",
"serde",
"slotmap",
@ -5622,7 +5718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"arrayvec 0.7.6",
"bytemuck",
"cfg-if",
"log",
@ -5959,7 +6055,7 @@ version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b84ea542ae85c715f07b082438a4231c3760539d902e11d093847a0b22963032"
dependencies = [
"base64",
"base64 0.22.1",
"data-url",
"flate2",
"fontdb 0.18.0",
@ -6036,6 +6132,25 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "wallpaper"
version = "3.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0547c84bf49b1096b20ce49736b86cd27f8225fc426665d3fba19e71e44c4d46"
dependencies = [
"dirs 1.0.5",
"enquote",
"rust-ini",
"winapi",
"winreg",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@ -6306,7 +6421,7 @@ version = "22.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d1c4ba43f80542cf63a0a6ed3134629ae73e8ab51e4b765a67f3aa062eb433"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"cfg_aliases 0.1.1",
"document-features",
"js-sys",
@ -6331,7 +6446,7 @@ version = "22.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348c840d1051b8e86c3bcd31206080c5e71e5933dabd79be1ce732b0b2f089a"
dependencies = [
"arrayvec",
"arrayvec 0.7.6",
"bit-vec",
"bitflags 2.10.0",
"cfg_aliases 0.1.1",
@ -6357,7 +6472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6bbf4b4de8b2a83c0401d9e5ae0080a2792055f25859a02bf9be97952bbed4f"
dependencies = [
"android_system_properties",
"arrayvec",
"arrayvec 0.7.6",
"ash",
"bit-set",
"bitflags 2.10.0",
@ -6955,6 +7070,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16cdb3898397cf7f624c294948669beafaeebc5577d5ec53d0afb76633593597"
dependencies = [
"winapi",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"

View file

@ -42,6 +42,7 @@ dirs = "5.0"
image = "0.25.9"
clap = { version = "4.5.54", features = ["derive"] }
env_logger = "0.11.8"
wallpaper = "3.2"
[dependencies.libcosmic]
git = "https://github.com/pop-os/libcosmic.git"

View file

@ -104,6 +104,10 @@ This document describes the implemented and planned features of Noctua, a modern
- **Properties panel**:
- Image metadata display
- File information
- Action buttons:
- Set as Wallpaper (works with COSMIC, GNOME, KDE, XFCE, and tiling WMs)
- Open With… (planned)
- Show in Folder (planned)
- Toggle with `i` key or toolbar button
- **Navigation panel** (Left sidebar):
- Toggle with `n` key or toolbar button
@ -116,6 +120,23 @@ Full keyboard-driven workflow:
- Pan: `Ctrl + ←` `Ctrl + →` `Ctrl + ↑` `Ctrl + ↓`
- Transform: `r` `Shift+r` `h` `v`
- Panels: `i` `n`
- Actions: `w` (Set as Wallpaper)
### Desktop Integration
#### Wallpaper Support (Implemented)
- **Set as Wallpaper**: One-click wallpaper setting with cross-desktop compatibility
- **Supported desktop environments**:
- COSMIC Desktop (direct config file integration)
- GNOME (via gsettings)
- KDE Plasma (via wallpaper crate)
- XFCE (via wallpaper crate)
- Tiling window managers (via feh)
- **Multiple access methods**:
- Keyboard shortcut: `w`
- Icon button in Properties panel
- Tooltip support for discoverability
- **Automatic fallback**: Tries multiple methods until one succeeds
### Configuration

View file

@ -75,6 +75,12 @@ All transformations are lossless and show in real-time.
| `i` | Toggle properties | Show/hide the properties panel (metadata)|
| `n` | Toggle navigation | Show/hide the navigation sidebar |
### Actions
| Key | Action | Description |
|:----|:-----------------------|:-----------------------------------------|
| `w` | Set as wallpaper | Set the current image as desktop wallpaper|
## Mouse Controls
### Zoom
@ -101,6 +107,26 @@ The header toolbar provides quick access to common operations:
### Right Side
- **Properties toggle**: Show/hide the metadata panel
## Properties Panel
The properties panel (toggle with `i`) displays image metadata and provides quick actions:
### Action Buttons
Located at the top-right of the properties panel:
- **Set as Wallpaper** (`w` key): Set the current image as your desktop wallpaper
- Works with COSMIC, GNOME, KDE, XFCE, and tiling window managers
- Automatically detects your desktop environment
- Falls back to alternative methods if the primary method fails
- **Open With** (planned): Open the image with another application
- **Show in Folder** (planned): Open the containing folder in your file manager
### Metadata Display
- **File Information**: Name, format, dimensions, file size, color type
- **Camera Information** (if available): Camera model, date taken, exposure settings, GPS location
## Footer Information
The footer displays useful information:

View file

@ -40,9 +40,15 @@ error-unsupported-format = Unsupported file format.
## Properties panel
panel-properties = Properties
panel-actions = Actions
meta-section-file = File Information
meta-section-exif = Camera Information
## Action buttons
action-set-wallpaper = Set as Wallpaper
action-open-with = Open With…
action-show-in-folder = Show in Folder
## Basic metadata
meta-filename = Name
meta-format = Format

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()
}