From 14affb1533ad4c6865c236cfcfd1918937e6f127 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Sat, 28 Mar 2026 22:35:18 +0100 Subject: [PATCH] perf: replace CPU blur with GPU blur via GskBlurNode (v0.5.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace image crate + disk cache blur with GPU-side GskBlurNode, symmetric with moonlock and moongreet. Removes ~15 transitive dependencies and ~160 lines of caching code. Blur now happens once on the GPU at widget realization — zero startup latency, no cache management needed. --- Cargo.lock | 134 +--------------------------- Cargo.toml | 4 +- DECISIONS.md | 10 +-- src/main.rs | 7 +- src/panel.rs | 244 ++++++++------------------------------------------- 5 files changed, 50 insertions(+), 349 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 297a14d..48a89a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # 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" @@ -85,18 +79,6 @@ 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" @@ -148,15 +130,6 @@ 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" @@ -223,15 +196,6 @@ 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" @@ -242,16 +206,6 @@ 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" @@ -676,21 +630,6 @@ 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" @@ -793,19 +732,9 @@ 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.4.1" +version = "0.5.0" dependencies = [ "dirs", "env_logger", @@ -813,9 +742,9 @@ dependencies = [ "gdk4", "glib", "glib-build-tools", + "graphene-rs", "gtk4", "gtk4-layer-shell", - "image", "log", "nix", "serde", @@ -823,16 +752,6 @@ 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" @@ -845,15 +764,6 @@ 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" @@ -908,19 +818,6 @@ 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" @@ -964,12 +861,6 @@ 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" @@ -1114,12 +1005,6 @@ 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" @@ -1510,18 +1395,3 @@ 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 5057d42..0696f95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moonset" -version = "0.4.1" +version = "0.5.0" edition = "2024" description = "Wayland session power menu with GTK4 and Layer Shell" license = "MIT" @@ -15,7 +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"] } +graphene-rs = { version = "0.22", package = "graphene-rs" } log = "0.4" env_logger = "0.11" diff --git a/DECISIONS.md b/DECISIONS.md index 72ac229..10decc3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -9,12 +9,12 @@ Architectural and design decisions for Moonset, in reverse chronological order. - **Tradeoffs**: Adds a field to `ActionDef` that most actions set to `false`. Acceptable because it makes the contract explicit and testable. - **How**: `ActionDef.quit_after: bool` — `true` for lock and logout, `false` for hibernate/reboot/shutdown. -## 2026-03-28 – Optional background blur via `image` crate +## 2026-03-28 – GPU blur via GskBlurNode replaces CPU blur -- **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. +- **Who**: Ragnar, Dom +- **Why**: CPU-side Gaussian blur (`image` crate) blocked startup and added caching complexity. moonlock already migrated to GPU blur. +- **Tradeoffs**: GPU blur quality is slightly different (box-blur approximation vs true Gaussian), acceptable for wallpaper backgrounds. Removes `image` crate dependency entirely. No disk cache needed. +- **How**: `Snapshot::push_blur()` + `GskRenderer::render_texture()` on `connect_realize`. Blur happens once on the GPU when the widget gets its renderer. Symmetric with moonlock and moongreet. ## 2026-03-28 – Use absolute paths for system binaries diff --git a/src/main.rs b/src/main.rs index bf0afa1..641bf5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -54,12 +54,13 @@ fn activate(app: >k::Application) { load_css(&display); // Resolve wallpaper once, decode texture once, share across all windows + // Blur is applied on the GPU via GskBlurNode at widget realization time. let config = config::load_config(None); let bg_path = config::resolve_background_path(&config); - let texture = panel::load_background_texture(&bg_path, config.background_blur); + let texture = panel::load_background_texture(&bg_path); // Panel on focused output (no set_monitor → compositor picks focused) - let panel = panel::create_panel_window(&texture, app); + let panel = panel::create_panel_window(&texture, config.background_blur, app); setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay); panel.present(); @@ -67,7 +68,7 @@ fn activate(app: >k::Application) { let monitors = display.monitors(); for i in 0..monitors.n_items() { if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::().ok()) { - let wallpaper = panel::create_wallpaper_window(&texture, app); + let wallpaper = panel::create_wallpaper_window(&texture, config.background_blur, app); setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top); wallpaper.set_monitor(Some(&monitor)); wallpaper.present(); diff --git a/src/panel.rs b/src/panel.rs index ed3e8f5..24bf10d 100644 --- a/src/panel.rs +++ b/src/panel.rs @@ -6,14 +6,10 @@ 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::fs; -use std::io::Write; -use std::os::unix::fs::OpenOptionsExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::rc::Rc; -use std::time::{Duration, SystemTime}; +use std::time::Duration; use crate::i18n::{load_strings, Strings}; use crate::power::{self, PowerError}; @@ -91,11 +87,11 @@ pub fn action_definitions() -> Vec { } /// 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 { +/// Blur is applied on the GPU via GskBlurNode at widget realization time. +pub fn load_background_texture(bg_path: &Path) -> gdk::Texture { let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX); - let texture = if bg_path.starts_with(crate::GRESOURCE_PREFIX) { + 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 { @@ -103,148 +99,28 @@ pub fn load_background_texture(bg_path: &Path, blur_radius: Option) -> gdk: gdk::Texture::from_file(&file).unwrap_or_else(|_| { gdk::Texture::from_resource(&fallback) }) - }; - - match blur_radius { - Some(sigma) if sigma > 0.0 => load_blurred_with_cache(bg_path, &texture, sigma), - _ => texture, } } -// -- Blur cache ---------------------------------------------------------------- +// -- GPU blur via GskBlurNode ------------------------------------------------- -const CACHE_PNG: &str = "blur-cache.png"; -const CACHE_META: &str = "blur-cache.meta"; - -fn blur_cache_dir() -> Option { - dirs::cache_dir().map(|d| d.join("moonset")) -} - -/// Build the cache key string for the current wallpaper + sigma. -fn build_cache_meta(bg_path: &Path, sigma: f32) -> Option { - if bg_path.starts_with("/dev/moonarch/") { - let binary = std::env::current_exe().ok()?; - let binary_mtime = fs::metadata(&binary) - .ok()? - .modified() - .ok()? - .duration_since(SystemTime::UNIX_EPOCH) - .ok()? - .as_secs(); - Some(format!( - "path={}\nbinary_mtime={}\nsigma={}\n", - bg_path.display(), binary_mtime, sigma, - )) - } else { - let meta = fs::metadata(bg_path).ok()?; - let mtime = meta - .modified() - .ok()? - .duration_since(SystemTime::UNIX_EPOCH) - .ok()? - .as_secs(); - Some(format!( - "path={}\nsize={}\nmtime={}\nsigma={}\n", - bg_path.display(), meta.len(), mtime, sigma, - )) - } -} - -/// Try to load a cached blurred texture if the cache key matches. -fn load_cached_blur(cache_dir: &Path, expected_meta: &str) -> Option { - let stored_meta = fs::read_to_string(cache_dir.join(CACHE_META)).ok()?; - if stored_meta != expected_meta { - log::debug!("Blur cache meta mismatch, will re-blur"); - return None; - } - let file = gio::File::for_path(cache_dir.join(CACHE_PNG)); - match gdk::Texture::from_file(&file) { - Ok(texture) => { - log::debug!("Loaded blurred wallpaper from cache"); - Some(texture) - } - Err(e) => { - log::debug!("Failed to load cached blur PNG: {e}"); - None - } - } -} - -/// Save a blurred texture to the cache directory. -fn save_blur_cache(cache_dir: &Path, texture: &gdk::Texture, meta: &str) { - if let Err(e) = save_blur_cache_inner(cache_dir, texture, meta) { - log::debug!("Failed to save blur cache: {e}"); - } -} - -fn save_blur_cache_inner( - cache_dir: &Path, +/// Render a blurred texture using the GPU via GskBlurNode. +fn render_blurred_texture( + widget: &impl IsA, texture: &gdk::Texture, - meta: &str, -) -> Result<(), Box> { - fs::create_dir_all(cache_dir)?; - - let png_bytes = texture.save_to_png_bytes(); - - let mut f = fs::OpenOptions::new() - .create(true).write(true).truncate(true).mode(0o600) - .open(cache_dir.join(CACHE_PNG))?; - f.write_all(&png_bytes)?; - - // Meta last — incomplete cache is treated as a miss on next start - let mut f = fs::OpenOptions::new() - .create(true).write(true).truncate(true).mode(0o600) - .open(cache_dir.join(CACHE_META))?; - f.write_all(meta.as_bytes())?; - - log::debug!("Saved blur cache to {}", cache_dir.display()); - Ok(()) -} - -/// Load blurred texture, using disk cache when available. -fn load_blurred_with_cache(bg_path: &Path, texture: &gdk::Texture, sigma: f32) -> gdk::Texture { - if let Some(cache_dir) = blur_cache_dir() { - if let Some(meta) = build_cache_meta(bg_path, sigma) { - if let Some(cached) = load_cached_blur(&cache_dir, &meta) { - return cached; - } - let blurred = apply_blur(texture, sigma); - save_blur_cache(&cache_dir, &blurred, &meta); - return blurred; - } - } - apply_blur(texture, sigma) -} - -// -- Blur implementation ------------------------------------------------------- - -/// 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]; - // download() yields GDK_MEMORY_DEFAULT = B8G8R8A8_PREMULTIPLIED (BGRA byte order). - texture.download(&mut pixel_data, stride); - - // Swap B↔R so image::RgbaImage channel semantics are correct. - for pixel in pixel_data.chunks_exact_mut(4) { - pixel.swap(0, 2); - } - - 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::R8g8b8a8Premultiplied, - &bytes, - stride, + sigma: f32, +) -> Option { + let native = widget.native()?; + let renderer = native.renderer()?; + let snapshot = gtk::Snapshot::new(); + let bounds = graphene_rs::Rect::new( + 0.0, 0.0, texture.width() as f32, texture.height() as f32, ); - mem_texture.upcast() + snapshot.push_blur(sigma as f64); + snapshot.append_texture(texture, &bounds); + snapshot.pop(); + let node = snapshot.to_node()?; + Some(renderer.render_texture(&node, None)) } /// Fade out all windows and quit the app after the CSS transition completes. @@ -259,13 +135,13 @@ fn fade_out_and_quit(app: >k::Application) { } /// Create a wallpaper-only window for secondary monitors. -pub fn create_wallpaper_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow { +pub fn create_wallpaper_window(texture: &gdk::Texture, blur_radius: Option, app: >k::Application) -> gtk::ApplicationWindow { let window = gtk::ApplicationWindow::builder() .application(app) .build(); window.add_css_class("wallpaper"); - let background = create_background_picture(texture); + let background = create_background_picture(texture, blur_radius); window.set_child(Some(&background)); // Fade-in on map @@ -283,7 +159,7 @@ pub fn create_wallpaper_window(texture: &gdk::Texture, app: >k::Application) - } /// Create the main panel window with action buttons and confirm flow. -pub fn create_panel_window(texture: &gdk::Texture, app: >k::Application) -> gtk::ApplicationWindow { +pub fn create_panel_window(texture: &gdk::Texture, blur_radius: Option, app: >k::Application) -> gtk::ApplicationWindow { let window = gtk::ApplicationWindow::builder() .application(app) .build(); @@ -305,7 +181,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: >k::Application) -> gt window.set_child(Some(&overlay)); // Background wallpaper - let background = create_background_picture(texture); + let background = create_background_picture(texture, blur_radius); overlay.set_child(Some(&background)); // Click on background dismisses the menu @@ -409,12 +285,22 @@ pub fn create_panel_window(texture: &gdk::Texture, app: >k::Application) -> gt window } -/// Create a Picture widget for the wallpaper background from a shared texture. -fn create_background_picture(texture: &gdk::Texture) -> gtk::Picture { +/// Create a Picture widget for the wallpaper background, optionally with GPU blur. +fn create_background_picture(texture: &gdk::Texture, blur_radius: Option) -> gtk::Picture { let background = gtk::Picture::for_paintable(texture); background.set_content_fit(gtk::ContentFit::Cover); background.set_hexpand(true); background.set_vexpand(true); + + if let Some(sigma) = blur_radius.filter(|s| *s > 0.0) { + let texture = texture.clone(); + background.connect_realize(move |picture| { + if let Some(blurred) = render_blurred_texture(picture, &texture, sigma) { + picture.set_paintable(Some(&blurred)); + } + }); + } + background } @@ -777,60 +663,4 @@ mod tests { } } - // -- Blur cache tests -- - - #[test] - fn build_cache_meta_for_file() { - let dir = tempfile::tempdir().unwrap(); - let file = dir.path().join("wallpaper.jpg"); - fs::write(&file, b"fake image").unwrap(); - let meta = build_cache_meta(&file, 20.0); - assert!(meta.is_some()); - let meta = meta.unwrap(); - assert!(meta.contains("path=")); - assert!(meta.contains("size=10")); - assert!(meta.contains("sigma=20")); - assert!(meta.contains("mtime=")); - } - - #[test] - fn build_cache_meta_for_gresource() { - let path = Path::new("/dev/moonarch/moonset/wallpaper.jpg"); - let meta = build_cache_meta(path, 15.0); - assert!(meta.is_some()); - let meta = meta.unwrap(); - assert!(meta.contains("binary_mtime=")); - assert!(meta.contains("sigma=15")); - assert!(!meta.contains("size=")); - } - - #[test] - fn build_cache_meta_missing_file() { - let meta = build_cache_meta(Path::new("/nonexistent/wallpaper.jpg"), 20.0); - assert!(meta.is_none()); - } - - #[test] - fn cache_meta_mismatch_returns_none() { - let dir = tempfile::tempdir().unwrap(); - fs::write( - dir.path().join(CACHE_META), - "path=/old.jpg\nsize=100\nmtime=1\nsigma=20\n", - ).unwrap(); - let result = load_cached_blur( - dir.path(), - "path=/new.jpg\nsize=200\nmtime=2\nsigma=20\n", - ); - assert!(result.is_none()); - } - - #[test] - fn cache_missing_meta_returns_none() { - let dir = tempfile::tempdir().unwrap(); - let result = load_cached_blur( - dir.path(), - "path=/any.jpg\nsize=1\nmtime=1\nsigma=20\n", - ); - assert!(result.is_none()); - } }