Compare commits
3 Commits
478caed8e0
...
4d8e306b74
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d8e306b74 | |||
| 2e88a9b6c4 | |||
| 412ed159a4 |
17
CHANGELOG.md
17
CHANGELOG.md
@ -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
2
Cargo.lock
generated
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -31,7 +31,8 @@ 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) {
|
||||||
|
Ok(parsed) => {
|
||||||
if parsed.background_path.is_some() {
|
if parsed.background_path.is_some() {
|
||||||
merged.background_path = parsed.background_path;
|
merged.background_path = parsed.background_path;
|
||||||
}
|
}
|
||||||
@ -39,6 +40,10 @@ pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
|
|||||||
merged.background_blur = parsed.background_blur;
|
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"));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/panel.rs
52
src/panel.rs
@ -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: >k::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: >k::Application) -> gtk::ApplicationWindow {
|
pub fn create_wallpaper_window(texture: &gdk::Texture, app: >k::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: >k::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: >k::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]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user