Compare commits

...

3 Commits

Author SHA1 Message Date
4d8e306b74 feat: add fade-out animation on dismiss for smooth visual exit
Without this, app.quit() destroys windows instantly, creating a jarring
pop-out. Now all windows fade out over 250ms (matching the fade-in)
before the app exits. Uses the same CSS opacity transition — just
removes the "visible" class and defers quit via glib timeout.
2026-03-28 21:50:03 +01:00
2e88a9b6c4 feat: activate fade-in animation for panel and wallpaper windows
The Rust code already adds a "visible" CSS class on map, but the
stylesheet had no corresponding opacity transition. Add 250ms ease-in
fade via GPU-accelerated CSS opacity to eliminate the visual pop-in.
2026-03-28 21:46:08 +01:00
412ed159a4 fix: address audit findings — blur channel mismatch, logout quit, config error logging
- Fix BGRA→RGBA channel swap in apply_blur so image::RgbaImage semantics
  match the actual pixel data from GDK texture download
- Logout now calls app.quit() like lock does, via new quit_after field on
  ActionDef (replaces fragile magic string comparison)
- Log TOML parse errors to stderr instead of silently ignoring
- Remove pointless zlib compression of JPEG wallpaper in GResource
- Add tests for quit_after behavior and config error handling
2026-03-28 21:39:34 +01:00
8 changed files with 125 additions and 15 deletions

View File

@ -3,6 +3,23 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/). Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.4.1] - 2026-03-28
### Added
- Fade-in/fade-out animation (250ms ease-in) for panel and wallpaper windows via CSS opacity transition
### 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 ## [0.4.0] - 2026-03-28
### Added ### Added

2
Cargo.lock generated
View File

@ -805,7 +805,7 @@ dependencies = [
[[package]] [[package]]
name = "moonset" name = "moonset"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"dirs", "dirs",
"env_logger", "env_logger",

View File

@ -1,6 +1,6 @@
[package] [package]
name = "moonset" name = "moonset"
version = "0.4.0" version = "0.4.1"
edition = "2024" edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell" description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT" license = "MIT"

View File

@ -2,6 +2,13 @@
Architectural and design decisions for Moonset, in reverse chronological order. 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 ## 2026-03-28 Optional background blur via `image` crate
- **Who**: Hekate, Dom - **Who**: Hekate, Dom

View File

@ -2,7 +2,7 @@
<gresources> <gresources>
<gresource prefix="/dev/moonarch/moonset"> <gresource prefix="/dev/moonarch/moonset">
<file>style.css</file> <file>style.css</file>
<file compressed="true">wallpaper.jpg</file> <file>wallpaper.jpg</file>
<file>default-avatar.svg</file> <file>default-avatar.svg</file>
</gresource> </gresource>
</gresources> </gresources>

View File

@ -6,11 +6,23 @@ window.panel {
background-color: @theme_bg_color; background-color: @theme_bg_color;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.panel.visible {
opacity: 1;
} }
/* Wallpaper-only window for secondary monitors */ /* Wallpaper-only window for secondary monitors */
window.wallpaper { window.wallpaper {
background-color: @theme_bg_color; background-color: @theme_bg_color;
opacity: 0;
transition: opacity 250ms ease-in;
}
window.wallpaper.visible {
opacity: 1;
} }
/* Round avatar image */ /* Round avatar image */

View File

@ -31,12 +31,17 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let mut merged = Config::default(); let mut merged = Config::default();
for path in paths { for path in paths {
if let Ok(content) = fs::read_to_string(path) { if let Ok(content) = fs::read_to_string(path) {
if let Ok(parsed) = toml::from_str::<Config>(&content) { match toml::from_str::<Config>(&content) {
if parsed.background_path.is_some() { Ok(parsed) => {
merged.background_path = parsed.background_path; 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() { Err(e) => {
merged.background_blur = parsed.background_blur; eprintln!("Warning: failed to parse {}: {e}", path.display());
} }
} }
} }
@ -185,4 +190,35 @@ mod tests {
let result = resolve_background_path_with(&config, Path::new("/nonexistent")); let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg")); 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));
}
} }

View File

@ -13,7 +13,7 @@ use std::io::Write;
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::time::SystemTime; use std::time::{Duration, SystemTime};
use crate::i18n::{load_strings, Strings}; use crate::i18n::{load_strings, Strings};
use crate::power::{self, PowerError}; use crate::power::{self, PowerError};
@ -31,6 +31,7 @@ pub struct ActionDef {
pub label_attr: fn(&Strings) -> &'static str, pub label_attr: fn(&Strings) -> &'static str,
pub error_attr: fn(&Strings) -> &'static str, pub error_attr: fn(&Strings) -> &'static str,
pub confirm_attr: Option<fn(&Strings) -> &'static str>, pub confirm_attr: Option<fn(&Strings) -> &'static str>,
pub quit_after: bool,
} }
/// All 5 power action definitions. /// All 5 power action definitions.
@ -44,6 +45,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.lock_label, label_attr: |s| s.lock_label,
error_attr: |s| s.lock_failed, error_attr: |s| s.lock_failed,
confirm_attr: None, confirm_attr: None,
quit_after: true,
}, },
ActionDef { ActionDef {
name: "logout", name: "logout",
@ -53,6 +55,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.logout_label, label_attr: |s| s.logout_label,
error_attr: |s| s.logout_failed, error_attr: |s| s.logout_failed,
confirm_attr: Some(|s| s.logout_confirm), confirm_attr: Some(|s| s.logout_confirm),
quit_after: true,
}, },
ActionDef { ActionDef {
name: "hibernate", name: "hibernate",
@ -62,6 +65,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.hibernate_label, label_attr: |s| s.hibernate_label,
error_attr: |s| s.hibernate_failed, error_attr: |s| s.hibernate_failed,
confirm_attr: Some(|s| s.hibernate_confirm), confirm_attr: Some(|s| s.hibernate_confirm),
quit_after: false,
}, },
ActionDef { ActionDef {
name: "reboot", name: "reboot",
@ -71,6 +75,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.reboot_label, label_attr: |s| s.reboot_label,
error_attr: |s| s.reboot_failed, error_attr: |s| s.reboot_failed,
confirm_attr: Some(|s| s.reboot_confirm), confirm_attr: Some(|s| s.reboot_confirm),
quit_after: false,
}, },
ActionDef { ActionDef {
name: "shutdown", name: "shutdown",
@ -80,6 +85,7 @@ pub fn action_definitions() -> Vec<ActionDef> {
label_attr: |s| s.shutdown_label, label_attr: |s| s.shutdown_label,
error_attr: |s| s.shutdown_failed, error_attr: |s| s.shutdown_failed,
confirm_attr: Some(|s| s.shutdown_confirm), 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 height = texture.height() as u32;
let stride = width as usize * 4; let stride = width as usize * 4;
let mut pixel_data = vec![0u8; stride * height as usize]; 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); 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) let img = image::RgbaImage::from_raw(width, height, pixel_data)
.expect("pixel buffer size matches texture dimensions"); .expect("pixel buffer size matches texture dimensions");
let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma); let blurred = imageops::blur(&image::DynamicImage::ImageRgba8(img), sigma);
@ -228,13 +240,24 @@ fn apply_blur(texture: &gdk::Texture, sigma: f32) -> gdk::Texture {
let mem_texture = gdk::MemoryTexture::new( let mem_texture = gdk::MemoryTexture::new(
width as i32, width as i32,
height as i32, height as i32,
gdk::MemoryFormat::B8g8r8a8Premultiplied, gdk::MemoryFormat::R8g8b8a8Premultiplied,
&bytes, &bytes,
stride, stride,
); );
mem_texture.upcast() mem_texture.upcast()
} }
/// Fade out all windows and quit the app after the CSS transition completes.
fn fade_out_and_quit(app: &gtk::Application) {
for window in app.windows() {
window.remove_css_class("visible");
}
let app = app.clone();
glib::timeout_add_local_once(Duration::from_millis(250), move || {
app.quit();
});
}
/// Create a wallpaper-only window for secondary monitors. /// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow { pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder() let window = gtk::ApplicationWindow::builder()
@ -291,7 +314,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
#[weak] #[weak]
app, app,
move |_, _, _, _| { move |_, _, _, _| {
app.quit(); fade_out_and_quit(&app);
} }
)); ));
background.add_controller(click_controller); background.add_controller(click_controller);
@ -361,7 +384,7 @@ pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gt
glib::Propagation::Proceed, glib::Propagation::Proceed,
move |_, keyval, _, _| { move |_, keyval, _, _| {
if keyval == gdk::Key::Escape { if keyval == gdk::Key::Escape {
app.quit(); fade_out_and_quit(&app);
glib::Propagation::Stop glib::Propagation::Stop
} else { } else {
glib::Propagation::Proceed glib::Propagation::Proceed
@ -564,6 +587,7 @@ fn execute_action(
let action_fn = action_def.action_fn; let action_fn = action_def.action_fn;
let action_name = action_def.name; let action_name = action_def.name;
let quit_after = action_def.quit_after;
let error_message = (action_def.error_attr)(strings).to_string(); let error_message = (action_def.error_attr)(strings).to_string();
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues // Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
@ -579,9 +603,8 @@ fn execute_action(
match result { match result {
Ok(Ok(())) => { Ok(Ok(())) => {
// Lock action: quit after successful execution if quit_after {
if action_name == "lock" { fade_out_and_quit(&app);
app.quit();
} }
} }
Ok(Err(e)) => { Ok(Err(e)) => {
@ -739,6 +762,21 @@ mod tests {
assert_eq!(confirm_fn(strings), "Wirklich abmelden?"); 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 -- // -- Blur cache tests --
#[test] #[test]