diff --git a/CHANGELOG.md b/CHANGELOG.md
index b13dc49..324554a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,19 @@
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
+## [0.4.1] - 2026-03-28
+
+### Fixed
+
+- Fix pixel format mismatch in blur path — `texture.download()` yields BGRA but was passed to `RgbaImage` without channel swap, now explicitly converts B↔R
+- Logout action now calls `app.quit()` to dismiss the menu immediately (previously only Lock did)
+- Log TOML parse errors to stderr instead of silently falling back to defaults
+
+### Changed
+
+- Replace magic string `"lock"` comparison with `quit_after` field on `ActionDef` for type-safe action dispatch
+- Remove `compressed="true"` from JPEG wallpaper in GResource — JPEG is already compressed, zlib overhead hurts startup for negligible size savings
+
## [0.4.0] - 2026-03-28
### Added
diff --git a/Cargo.toml b/Cargo.toml
index 4b32986..5057d42 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "moonset"
-version = "0.4.0"
+version = "0.4.1"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
diff --git a/DECISIONS.md b/DECISIONS.md
index dcc9752..72ac229 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -2,6 +2,13 @@
Architectural and design decisions for Moonset, in reverse chronological order.
+## 2026-03-28 – Replace action name dispatch with `quit_after` field
+
+- **Who**: Hekate, Dom
+- **Why**: Post-action behavior (quit the app or not) was controlled by comparing `action_name == "lock"` — a magic string duplicated from the action definition. Renaming an action would silently break the dispatch.
+- **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
- **Who**: Hekate, Dom
diff --git a/resources/resources.gresource.xml b/resources/resources.gresource.xml
index 35a93a7..c379862 100644
--- a/resources/resources.gresource.xml
+++ b/resources/resources.gresource.xml
@@ -2,7 +2,7 @@
style.css
- wallpaper.jpg
+ wallpaper.jpg
default-avatar.svg
diff --git a/src/config.rs b/src/config.rs
index c2b7ecf..bacc87c 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -31,12 +31,17 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default();
for path in paths {
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;
+ match toml::from_str::(&content) {
+ Ok(parsed) => {
+ 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 parsed.background_blur.is_some() {
- merged.background_blur = parsed.background_blur;
+ Err(e) => {
+ eprintln!("Warning: failed to parse {}: {e}", path.display());
}
}
}
@@ -185,4 +190,35 @@ mod tests {
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
}
+
+ #[test]
+ fn load_config_ignores_invalid_toml_syntax() {
+ let dir = tempfile::tempdir().unwrap();
+ let conf = dir.path().join("bad.toml");
+ fs::write(&conf, "this is not valid [[[ toml").unwrap();
+ let paths = vec![conf];
+ let config = load_config(Some(&paths));
+ assert!(config.background_path.is_none());
+ assert!(config.background_blur.is_none());
+ }
+
+ #[test]
+ fn load_config_ignores_wrong_field_types() {
+ let dir = tempfile::tempdir().unwrap();
+ let conf = dir.path().join("wrong_type.toml");
+ fs::write(&conf, "background_blur = \"not_a_number\"\n").unwrap();
+ let paths = vec![conf];
+ let config = load_config(Some(&paths));
+ assert!(config.background_blur.is_none());
+ }
+
+ #[test]
+ fn load_config_accepts_negative_blur() {
+ let dir = tempfile::tempdir().unwrap();
+ let conf = dir.path().join("negative.toml");
+ fs::write(&conf, "background_blur = -5.0\n").unwrap();
+ let paths = vec![conf];
+ let config = load_config(Some(&paths));
+ assert_eq!(config.background_blur, Some(-5.0));
+ }
}
diff --git a/src/panel.rs b/src/panel.rs
index 3db2554..2423d01 100644
--- a/src/panel.rs
+++ b/src/panel.rs
@@ -31,6 +31,7 @@ pub struct ActionDef {
pub label_attr: fn(&Strings) -> &'static str,
pub error_attr: fn(&Strings) -> &'static str,
pub confirm_attr: Option &'static str>,
+ pub quit_after: bool,
}
/// All 5 power action definitions.
@@ -44,6 +45,7 @@ pub fn action_definitions() -> Vec {
label_attr: |s| s.lock_label,
error_attr: |s| s.lock_failed,
confirm_attr: None,
+ quit_after: true,
},
ActionDef {
name: "logout",
@@ -53,6 +55,7 @@ pub fn action_definitions() -> Vec {
label_attr: |s| s.logout_label,
error_attr: |s| s.logout_failed,
confirm_attr: Some(|s| s.logout_confirm),
+ quit_after: true,
},
ActionDef {
name: "hibernate",
@@ -62,6 +65,7 @@ pub fn action_definitions() -> Vec {
label_attr: |s| s.hibernate_label,
error_attr: |s| s.hibernate_failed,
confirm_attr: Some(|s| s.hibernate_confirm),
+ quit_after: false,
},
ActionDef {
name: "reboot",
@@ -71,6 +75,7 @@ pub fn action_definitions() -> Vec {
label_attr: |s| s.reboot_label,
error_attr: |s| s.reboot_failed,
confirm_attr: Some(|s| s.reboot_confirm),
+ quit_after: false,
},
ActionDef {
name: "shutdown",
@@ -80,6 +85,7 @@ pub fn action_definitions() -> Vec {
label_attr: |s| s.shutdown_label,
error_attr: |s| s.shutdown_failed,
confirm_attr: Some(|s| s.shutdown_confirm),
+ quit_after: false,
},
]
}
@@ -218,8 +224,14 @@ fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
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);
@@ -228,7 +240,7 @@ fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
let mem_texture = gdk::MemoryTexture::new(
width as i32,
height as i32,
- gdk::MemoryFormat::B8g8r8a8Premultiplied,
+ gdk::MemoryFormat::R8g8b8a8Premultiplied,
&bytes,
stride,
);
@@ -564,6 +576,7 @@ fn execute_action(
let action_fn = action_def.action_fn;
let action_name = action_def.name;
+ let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string();
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
@@ -579,8 +592,7 @@ fn execute_action(
match result {
Ok(Ok(())) => {
- // Lock action: quit after successful execution
- if action_name == "lock" {
+ if quit_after {
app.quit();
}
}
@@ -739,6 +751,21 @@ mod tests {
assert_eq!(confirm_fn(strings), "Wirklich abmelden?");
}
+ #[test]
+ fn lock_and_logout_quit_after() {
+ let defs = action_definitions();
+ assert!(defs[0].quit_after, "lock should quit after");
+ assert!(defs[1].quit_after, "logout should quit after");
+ }
+
+ #[test]
+ fn destructive_actions_do_not_quit_after() {
+ let defs = action_definitions();
+ for def in &defs[2..] {
+ assert!(!def.quit_after, "{} should not quit after", def.name);
+ }
+ }
+
// -- Blur cache tests --
#[test]