diff --git a/Cargo.lock b/Cargo.lock index 2179842..bd4293a 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 = "anyhow" version = "1.0.102" @@ -20,6 +26,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" @@ -65,6 +83,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -87,6 +114,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" @@ -97,6 +133,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" @@ -510,6 +556,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" @@ -573,9 +634,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 = "moonlock" -version = "0.4.2" +version = "0.5.0" dependencies = [ "gdk-pixbuf", "gdk4", @@ -584,6 +655,7 @@ dependencies = [ "glib-build-tools", "gtk4", "gtk4-session-lock", + "image", "libc", "log", "nix", @@ -594,6 +666,16 @@ dependencies = [ "zeroize", ] +[[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" @@ -606,6 +688,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" @@ -648,6 +739,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 = "prettyplease" version = "0.2.37" @@ -676,6 +780,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" @@ -780,6 +890,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" @@ -1168,3 +1284,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 f1bc811..7f5f8f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] } nix = { version = "0.29", features = ["user"] } zeroize = { version = "1", features = ["derive"] } libc = "0.2" +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } log = "0.4" systemd-journal-logger = "2.2" diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..3dd8b8e --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,17 @@ +# Decisions + +Architectural and design decisions for Moonlock, in reverse chronological order. + +## 2026-03-28 – Optional background blur via `image` crate + +- **Who**: Nyx, Dom +- **Why**: Consistent with moonset/moongreet — blurred wallpaper as lockscreen background is a common UX pattern +- **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 – Shared wallpaper texture pattern (aligned with moonset/moongreet) + +- **Who**: Nyx, Dom +- **Why**: Previously loaded wallpaper per-window via `Picture::for_filename()`. Multi-monitor setups decoded the JPEG redundantly. Blur feature requires texture pixel access anyway. +- **Tradeoffs**: Slightly more code in main.rs (texture loaded before window creation), but avoids redundant decoding and enables the blur feature. +- **How**: `load_background_texture()` in lockscreen.rs decodes once, `create_background_picture()` wraps shared `gdk::Texture` in `gtk::Picture`. Same pattern as moonset/moongreet. diff --git a/src/config.rs b/src/config.rs index 0ff77d4..a667914 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ fn default_config_paths() -> Vec { #[derive(Debug, Clone, Default, Deserialize)] struct RawConfig { pub background_path: Option, + pub background_blur: Option, pub fingerprint_enabled: Option, } @@ -28,6 +29,7 @@ struct RawConfig { #[derive(Debug, Clone)] pub struct Config { pub background_path: Option, + pub background_blur: Option, pub fingerprint_enabled: bool, } @@ -35,6 +37,7 @@ impl Default for Config { fn default() -> Self { Config { background_path: None, + background_blur: None, fingerprint_enabled: true, } } @@ -48,6 +51,7 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config { if let Ok(content) = fs::read_to_string(path) { if let Ok(parsed) = toml::from_str::(&content) { if parsed.background_path.is_some() { merged.background_path = parsed.background_path; } + if parsed.background_blur.is_some() { merged.background_blur = parsed.background_blur; } if let Some(fp) = parsed.fingerprint_enabled { merged.fingerprint_enabled = fp; } } } @@ -72,7 +76,7 @@ pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) mod tests { use super::*; - #[test] fn default_config() { let c = Config::default(); assert!(c.background_path.is_none()); assert!(c.fingerprint_enabled); } + #[test] fn default_config() { let c = Config::default(); assert!(c.background_path.is_none()); assert!(c.background_blur.is_none()); assert!(c.fingerprint_enabled); } #[test] fn load_default_fingerprint_true() { let dir = tempfile::tempdir().unwrap(); let conf = dir.path().join("moonlock.toml"); @@ -83,15 +87,23 @@ mod tests { #[test] fn load_background() { let dir = tempfile::tempdir().unwrap(); let conf = dir.path().join("moonlock.toml"); - fs::write(&conf, "background_path = \"/custom/bg.jpg\"\nfingerprint_enabled = false\n").unwrap(); + fs::write(&conf, "background_path = \"/custom/bg.jpg\"\nbackground_blur = 15.0\nfingerprint_enabled = false\n").unwrap(); let c = load_config(Some(&[conf])); assert_eq!(c.background_path.as_deref(), Some("/custom/bg.jpg")); + assert_eq!(c.background_blur, Some(15.0)); assert!(!c.fingerprint_enabled); } + #[test] fn load_blur_optional() { + let dir = tempfile::tempdir().unwrap(); + let conf = dir.path().join("moonlock.toml"); + fs::write(&conf, "background_path = \"/bg.jpg\"\n").unwrap(); + let c = load_config(Some(&[conf])); + assert!(c.background_blur.is_none()); + } #[test] fn resolve_config_path() { let dir = tempfile::tempdir().unwrap(); let wp = dir.path().join("bg.jpg"); fs::write(&wp, "fake").unwrap(); - let c = Config { background_path: Some(wp.to_str().unwrap().to_string()), fingerprint_enabled: true }; + let c = Config { background_path: Some(wp.to_str().unwrap().to_string()), ..Config::default() }; assert_eq!(resolve_background_path_with(&c, Path::new("/nonexistent")), wp); } #[test] fn empty_user_config_preserves_system_fingerprint() { diff --git a/src/lockscreen.rs b/src/lockscreen.rs index 6170bf7..165f4e6 100644 --- a/src/lockscreen.rs +++ b/src/lockscreen.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; @@ -41,7 +42,7 @@ struct LockscreenState { /// Create a lockscreen window for a single monitor. /// Fingerprint is not initialized here — use `wire_fingerprint()` after async init. pub fn create_lockscreen_window( - bg_path: &Path, + bg_texture: &gdk::Texture, _config: &Config, app: >k::Application, unlock_callback: Rc, @@ -82,7 +83,7 @@ pub fn create_lockscreen_window( window.set_child(Some(&overlay)); // Background wallpaper - let background = create_background_picture(bg_path); + let background = create_background_picture(bg_texture); overlay.set_child(Some(&background)); // Centered vertical box @@ -413,13 +414,53 @@ pub fn start_fingerprint( }); } -/// Create a Picture widget for the wallpaper background. -fn create_background_picture(bg_path: &Path) -> gtk::Picture { - let background = if bg_path.starts_with("/dev/moonarch/moonlock") { - gtk::Picture::for_resource(bg_path.to_str().unwrap_or("")) +/// Load the wallpaper as a texture once, for sharing across all windows. +/// 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 = "/dev/moonarch/moonlock/wallpaper.jpg"; + + let texture = if bg_path.starts_with("/dev/moonarch/moonlock") { + let resource_path = bg_path.to_str().unwrap_or(fallback); + gdk::Texture::from_resource(resource_path) } else { - gtk::Picture::for_filename(bg_path.to_str().unwrap_or("")) + let file = gio::File::for_path(bg_path); + 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 Picture widget for the wallpaper background from a shared texture. +fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture { + let background = gtk::Picture::for_paintable(texture); background.set_content_fit(gtk::ContentFit::Cover); background.set_hexpand(true); background.set_vexpand(true); diff --git a/src/main.rs b/src/main.rs index c24ae04..85019ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,6 @@ use gtk4::prelude::*; use gtk4::{self as gtk, gio}; use gtk4_session_lock; use std::cell::RefCell; -use std::path::PathBuf; use std::rc::Rc; use crate::fingerprint::FingerprintListener; @@ -42,14 +41,15 @@ fn activate(app: >k::Application) { let config = config::load_config(None); let bg_path = config::resolve_background_path(&config); + let bg_texture = lockscreen::load_background_texture(&bg_path, config.background_blur); if gtk4_session_lock::is_supported() { - activate_with_session_lock(app, &display, &bg_path, &config); + activate_with_session_lock(app, &display, &bg_texture, &config); } else { #[cfg(debug_assertions)] { log::warn!("ext-session-lock-v1 not supported — running in development mode"); - activate_without_lock(app, &bg_path, &config); + activate_without_lock(app, &bg_texture, &config); } #[cfg(not(debug_assertions))] { @@ -62,7 +62,7 @@ fn activate(app: >k::Application) { fn activate_with_session_lock( app: >k::Application, display: &gdk::Display, - bg_path: &PathBuf, + bg_texture: &gdk::Texture, config: &config::Config, ) { let lock = gtk4_session_lock::Instance::new(); @@ -87,7 +87,7 @@ fn activate_with_session_lock( .and_then(|obj| obj.downcast::().ok()) { let handles = lockscreen::create_lockscreen_window( - bg_path, + bg_texture, config, app, unlock_callback.clone(), @@ -144,7 +144,7 @@ fn init_fingerprint_async(all_handles: Vec) { #[cfg(debug_assertions)] fn activate_without_lock( app: >k::Application, - bg_path: &PathBuf, + bg_texture: &gdk::Texture, config: &config::Config, ) { let app_clone = app.clone(); @@ -153,7 +153,7 @@ fn activate_without_lock( }); let handles = lockscreen::create_lockscreen_window( - bg_path, + bg_texture, config, app, unlock_callback,