diff --git a/Cargo.lock b/Cargo.lock index e313700..5c644c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -79,6 +85,18 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cairo-rs" version = "0.22.0" @@ -130,6 +148,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "dirs" version = "6.0.0" @@ -196,6 +223,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -206,6 +242,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -630,6 +676,21 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -732,9 +793,19 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "moonset" -version = "0.1.0" +version = "0.1.1" dependencies = [ "dirs", "env_logger", @@ -744,6 +815,7 @@ dependencies = [ "glib-build-tools", "gtk4", "gtk4-layer-shell", + "image", "log", "nix", "serde", @@ -751,6 +823,16 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nix" version = "0.29.0" @@ -763,6 +845,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -817,6 +908,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -860,6 +964,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quote" version = "1.0.45" @@ -1004,6 +1114,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "slab" version = "0.4.12" @@ -1394,3 +1510,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 3c4728e..1a03629 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ toml = "0.8" dirs = "6" serde = { version = "1", features = ["derive"] } nix = { version = "0.29", features = ["user"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } log = "0.4" env_logger = "0.11" diff --git a/DECISIONS.md b/DECISIONS.md index 7d8dd51..dcc9752 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,6 +2,13 @@ Architectural and design decisions for Moonset, in reverse chronological order. +## 2026-03-28 – Optional background blur via `image` crate + +- **Who**: Hekate, Dom +- **Why**: Blurred wallpaper as background is a common UX pattern for overlay menus +- **Tradeoffs**: Adds `image` crate dependency (~15 transitive crates); CPU-side Gaussian blur at load time adds startup latency proportional to image size and sigma. Acceptable because blur runs once and the texture is shared across monitors. +- **How**: `load_background_texture(bg_path, blur_radius)` loads texture, optionally applies `imageops::blur()`, returns `gdk::Texture`. Config option `background_blur: Option` in TOML. + ## 2026-03-28 – Use absolute paths for system binaries - **Who**: Hekate, Dom diff --git a/src/config.rs b/src/config.rs index d3138a2..c2b7ecf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,6 +20,7 @@ fn default_config_paths() -> Vec { #[derive(Debug, Clone, Default, Deserialize)] pub struct Config { pub background_path: Option, + pub background_blur: Option, } /// Load config from TOML files. Later paths override earlier ones. @@ -34,6 +35,9 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { if parsed.background_path.is_some() { merged.background_path = parsed.background_path; } + if parsed.background_blur.is_some() { + merged.background_blur = parsed.background_blur; + } } } } @@ -76,6 +80,7 @@ mod tests { fn default_config_has_none_background() { let config = Config::default(); assert!(config.background_path.is_none()); + assert!(config.background_blur.is_none()); } #[test] @@ -95,6 +100,28 @@ mod tests { assert_eq!(config.background_path.as_deref(), Some("/custom/wallpaper.jpg")); } + #[test] + fn load_config_reads_background_blur() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonset.toml"); + fs::write(&conf, "background_blur = 20.0\n").unwrap(); + let paths = vec![conf]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_blur, Some(20.0)); + } + + #[test] + fn load_config_blur_override() { + let dir = tempfile::tempdir().unwrap(); + let conf1 = dir.path().join("first.toml"); + let conf2 = dir.path().join("second.toml"); + fs::write(&conf1, "background_blur = 10.0\n").unwrap(); + fs::write(&conf2, "background_blur = 25.0\n").unwrap(); + let paths = vec![conf1, conf2]; + let config = load_config(Some(&paths)); + assert_eq!(config.background_blur, Some(25.0)); + } + #[test] fn load_config_later_paths_override_earlier() { let dir = tempfile::tempdir().unwrap(); @@ -124,6 +151,7 @@ mod tests { fs::write(&wallpaper, "fake").unwrap(); let config = Config { background_path: Some(wallpaper.to_str().unwrap().to_string()), + ..Config::default() }; assert_eq!( resolve_background_path_with(&config, Path::new("/nonexistent")), @@ -135,6 +163,7 @@ mod tests { fn resolve_ignores_config_path_when_file_missing() { let config = Config { background_path: Some("/nonexistent/wallpaper.jpg".to_string()), + ..Config::default() }; let result = resolve_background_path_with(&config, Path::new("/nonexistent")); // Falls through to gresource fallback diff --git a/src/main.rs b/src/main.rs index 9a972a8..bf0afa1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,7 +56,7 @@ fn activate(app: >k::Application) { // Resolve wallpaper once, decode texture once, share across all windows let config = config::load_config(None); let bg_path = config::resolve_background_path(&config); - let texture = panel::load_background_texture(&bg_path); + let texture = panel::load_background_texture(&bg_path, config.background_blur); // Panel on focused output (no set_monitor → compositor picks focused) let panel = panel::create_panel_window(&texture, app); diff --git a/src/panel.rs b/src/panel.rs index 0510689..b15d86b 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -6,6 +6,7 @@ use gdk_pixbuf::Pixbuf; use glib::clone; use gtk4::prelude::*; use gtk4::{self as gtk, gio}; +use image::imageops; use std::cell::RefCell; use std::path::Path; use std::rc::Rc; @@ -80,10 +81,11 @@ pub fn action_definitions() -> Vec { } /// Load the wallpaper as a texture once, for sharing across all windows. -pub fn load_background_texture(bg_path: &Path) -> gdk::Texture { +/// When `blur_radius` is `Some(sigma)` with sigma > 0, a Gaussian blur is applied. +pub fn load_background_texture(bg_path: &Path, blur_radius: Option) -> gdk::Texture { let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX); - if bg_path.starts_with(crate::GRESOURCE_PREFIX) { + let texture = if bg_path.starts_with(crate::GRESOURCE_PREFIX) { let resource_path = bg_path.to_str().unwrap_or(&fallback); gdk::Texture::from_resource(resource_path) } else { @@ -91,9 +93,37 @@ pub fn load_background_texture(bg_path: &Path) -> gdk::Texture { gdk::Texture::from_file(&file).unwrap_or_else(|_| { gdk::Texture::from_resource(&fallback) }) + }; + + match blur_radius { + Some(sigma) if sigma > 0.0 => apply_blur(&texture, sigma), + _ => texture, } } +/// Apply Gaussian blur to a texture and return a blurred texture. +fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture { + let width = texture.width() as u32; + let height = texture.height() as u32; + let stride = width as usize * 4; + let mut pixel_data = vec![0u8; stride * height as usize]; + texture.download(&mut pixel_data, stride); + + let img = image::RgbaImage::from_raw(width, height, pixel_data) + .expect("pixel buffer size matches texture dimensions"); + let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma); + + let bytes = glib::Bytes::from(blurred.as_raw()); + let mem_texture = gdk::MemoryTexture::new( + width as i32, + height as i32, + gdk::MemoryFormat::B8g8r8a8Premultiplied, + &bytes, + stride, + ); + mem_texture.upcast() +} + /// Create a wallpaper-only window for secondary monitors. pub fn create_wallpaper_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow { let window = gtk::ApplicationWindow::builder()