11 Commits

Author SHA1 Message Date
nevaforget 622b06da3f chore: bump version to 0.4.0 2026-03-28 14:55:17 +01:00
nevaforget 529a1a54ae feat: add optional background blur via image crate
Gaussian blur applied at texture load time when `background_blur` is
set in moonset.toml. Blur runs once, result is shared across monitors.
2026-03-28 14:53:04 +01:00
nevaforget 473bed479a docs: add CHANGELOG.md, DECISIONS.md, bump version to 0.1.1
Add CHANGELOG documenting all changes since 0.1.0 and the initial
release. Add DECISIONS.md as an architectural decision log. Update
CLAUDE.md to reflect current architecture. Bump to 0.1.1 for the
security and correctness fixes in the previous commit.
2026-03-28 10:17:22 +01:00
nevaforget 496a7a4c72 fix: address audit findings — security, performance, and correctness
- Use absolute paths for all binaries in power.rs to prevent PATH hijacking
- Implement POWER_TIMEOUT via try_wait() polling (was declared but unused)
- Fix potential panic in load_background_texture when GResource path
  fails to_str() — now falls back to known wallpaper resource path
- Compress wallpaper.jpg in GResource bundle (saves ~374 KB in binary)
- Merge double idle_add_local_once into single cycle for faster focus
- Centralize GRESOURCE_PREFIX as pub(crate) const in main.rs
- Fix fallback user UID from 0 (root) to u32::MAX
- Fix CSS comment: "square card" → "circular card" (border-radius: 50%)
2026-03-28 10:13:18 +01:00
nevaforget 2d1d364270 i18n: migrate German text to English, remove stale journal
Translate README.md and config/moonset.toml comments from German
to English to enforce the repo language policy. Remove journal.md
as it was a one-time snapshot, not an actively maintained document.
2026-03-28 09:53:10 +01:00
nevaforget b22172c3a0 perf: optimize startup by caching icons, texture, and async avatar
- Replace manual icon theme lookup + Pixbuf scaling with native
  GTK4 Image::from_icon_name() (uses internal cache + GPU rendering)
- Decode wallpaper texture once and share across all windows
  instead of N+1 separate JPEG decodes
- Load file-based avatars asynchronously via gio::spawn_blocking
  to avoid blocking the UI thread
2026-03-28 09:47:47 +01:00
nevaforget d6979c1792 chore: remove Python implementation and build config
Rust rewrite provides full feature parity. Python sources,
tests, pyproject.toml, and uv.lock are no longer needed.
2026-03-27 16:11:54 +01:00
nevaforget c2e3077159 merge: rust-rewrite branch into main 2026-03-27 16:10:41 +01:00
nevaforget e66ef76b4d feat: rewrite moonset in Rust (gtk4-rs + gtk4-layer-shell)
Feature-parity with Python v0.2.0. Same CSS, same UI, same actions.
Single 3.1 MB binary with embedded resources (CSS, wallpaper, avatar).

Modules: power.rs, i18n.rs, config.rs, users.rs, panel.rs, main.rs
45 unit tests passing. Python sources retained as reference.
2026-03-27 16:09:51 +01:00
nevaforget 9deaaacc33 chore: remove social.md 2026-03-27 15:25:11 +01:00
nevaforget 270689a5c5 docs: update journal and social for v0.2.0 2026-03-27 15:23:31 +01:00
38 changed files with 3212 additions and 1495 deletions
+1 -10
View File
@@ -1,13 +1,4 @@
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.venv/
.pytest_cache/
.pyright/
*.egg
/target
# makepkg build artifacts
pkg/src/
+44
View File
@@ -0,0 +1,44 @@
# Changelog
All notable changes to this project will be documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/).
## [0.4.0] - 2026-03-28
### Added
- Optional background blur via `background_blur` config option (Gaussian blur, `image` crate)
## [0.1.1] - 2026-03-28
### Fixed
- Use absolute paths for all system binaries (`systemctl`, `loginctl`, `niri`, `moonlock`) to prevent PATH hijacking
- Implement `POWER_TIMEOUT` (30s) via `try_wait()` polling — previously declared but unused, leaving power actions able to block indefinitely
- Prevent panic in `load_background_texture` when GResource path contains non-UTF-8 bytes — now falls back to known wallpaper resource
- Fix fallback user UID from `0` (root) to `u32::MAX` as a safe sentinel value
- Fix CSS comment incorrectly describing circular buttons as "square card"
### Changed
- Compress wallpaper in GResource bundle (`compressed="true"`) to reduce binary size
- Merge double `idle_add_local_once` into single idle cycle for faster keyboard focus on launch
- Centralize `GRESOURCE_PREFIX` as `pub(crate) const` in `main.rs` (was duplicated in `config.rs`, `users.rs`, and literal strings in `panel.rs`)
- Translate README.md and config comments from German to English
- Remove stale `journal.md` (one-time development notes, not actively maintained)
## [0.1.0] - 2026-03-27
### Added
- Rust rewrite of the Python power menu (gtk4-rs + gtk4-layer-shell)
- 5 power actions: Lock, Logout, Hibernate, Reboot, Shutdown
- Inline confirmation for destructive actions (all except Lock)
- Multi-monitor wallpaper support via shared `gdk::Texture`
- DE/EN localization with automatic locale detection
- TOML configuration for custom wallpaper path
- GResource bundle for CSS, wallpaper, and default avatar
- Async power actions via `glib::spawn_future_local` + `gio::spawn_blocking`
- Async avatar loading (file-based avatars decoded off UI thread)
- Cached icon loading at startup
- 45 unit tests
+23 -17
View File
@@ -4,49 +4,55 @@
## Projekt
Moonset ist ein Wayland Session Power Menu, gebaut mit Python + GTK4 + gtk4-layer-shell.
Moonset ist ein Wayland Session Power Menu, gebaut mit Rust + gtk4-rs + gtk4-layer-shell.
Teil des Moonarch-Ökosystems. Per Keybind aufrufbares Overlay mit 5 Aktionen:
Lock, Logout, Hibernate, Reboot, Shutdown.
## Tech-Stack
- Python 3.11+, PyGObject (GTK 4.0)
- gtk4-layer-shell für Wayland Layer Shell (OVERLAY Layer)
- pytest für Tests
- Rust (Edition 2024), gtk4-rs 0.11, glib 0.22
- gtk4-layer-shell 0.8 für Wayland Layer Shell (OVERLAY Layer)
- `cargo test` für Unit-Tests
## Projektstruktur
- `src/moonset/` — Quellcode
- `src/moonset/data/` — Package-Assets (Fallback-Wallpaper)
- `tests/` — pytest Tests
- `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs)
- `resources/` — GResource-Assets (style.css, wallpaper.jpg komprimiert, default-avatar.svg)
- `config/` — Beispiel-Konfigurationsdateien
## Kommandos
```bash
# Tests ausführen
uv run pytest tests/ -v
cargo test
# Typ-Checks
uv run pyright src/
# Release-Build
cargo build --release
# Power-Menu starten (in Niri-Session)
uv run moonset
LD_PRELOAD=/usr/lib/libgtk4-layer-shell.so ./target/release/moonset
```
## Architektur
- `power.py` — 5 Power-Action-Wrapper (lock, logout, hibernate, reboot, shutdown)
- `i18n.py` — Locale-Erkennung und String-Tabellen (DE/EN)
- `config.py` — TOML-Config + Wallpaper-Fallback
- `panel.py` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `style.css` — Catppuccin Mocha Theme
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor, zentrale `GRESOURCE_PREFIX`-Konstante
- `power.rs` — 5 Power-Action-Wrapper mit absoluten Pfaden und 30s Timeout (lock, logout, hibernate, reboot, shutdown)
- `i18n.rs` — Locale-Erkennung und String-Tabellen (DE/EN)
- `config.rs` — TOML-Config + Wallpaper-Fallback
- `panel.rs` — GTK4 UI (Action-Buttons, Inline-Confirmation, WallpaperWindow)
- `users.rs` — User-Erkennung, Avatar-Loading (AccountsService, ~/.face, GResource-Fallback)
- `resources/style.css` — GTK-Theme-Colors für Konsistenz mit dem aktiven Desktop-Theme
## Design Decisions
Siehe `DECISIONS.md` für das vollständige Entscheidungsprotokoll.
Kurzfassung der wichtigsten Entscheidungen:
- **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
- **Absolute Pfade für Binaries**: `/usr/bin/systemctl` etc. statt relativer Pfade (Security)
- **GResource-Bundle**: CSS, Wallpaper (komprimiert) und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` mit 30s Timeout
Generated
+1527
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "moonset"
version = "0.4.0"
edition = "2024"
description = "Wayland session power menu with GTK4 and Layer Shell"
license = "MIT"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_10"] }
gtk4-layer-shell = "0.8"
glib = "0.22"
gdk4 = "0.11"
gdk-pixbuf = "0.22"
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"] }
log = "0.4"
env_logger = "0.11"
[dev-dependencies]
tempfile = "3"
[build-dependencies]
glib-build-tools = "0.22"
+59
View File
@@ -0,0 +1,59 @@
# Decisions
Architectural and design decisions for Moonset, in reverse chronological order.
## 2026-03-28 Optional background blur via `image` crate
- **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<f32>` in TOML.
## 2026-03-28 Use absolute paths for system binaries
- **Who**: Hekate, Dom
- **Why**: Security audit flagged PATH hijacking risk — relative binary names allow a malicious `$PATH` entry to intercept `systemctl`, `loginctl`, etc.
- **Tradeoffs**: Hardcoded paths reduce portability to non-Arch distros where binaries may live elsewhere (e.g. `/sbin/`). Acceptable because Moonarch targets Arch Linux exclusively.
- **How**: All five power action wrappers now use `/usr/bin/` prefixed paths.
## 2026-03-28 Implement power action timeout via try_wait polling
- **Who**: Hekate, Dom
- **Why**: `POWER_TIMEOUT` and `PowerError::Timeout` were declared but never wired up. A hanging `systemctl hibernate` (e.g. blocked NFS mount) would freeze the power menu indefinitely.
- **Tradeoffs**: Polling with `try_wait()` + 100ms sleep is slightly less efficient than a dedicated timeout crate, but avoids adding a dependency for a single use case.
- **How**: `run_command` now polls `child.try_wait()` against a 30s deadline, kills the child on timeout.
## 2026-03-28 Centralize GRESOURCE_PREFIX
- **Who**: Hekate, Dom
- **Why**: The string `/dev/moonarch/moonset` was duplicated in `config.rs`, `users.rs`, and as literal strings in `panel.rs` and `main.rs`. Changing the application ID would require edits in 4+ locations.
- **Tradeoffs**: Modules now depend on `crate::GRESOURCE_PREFIX` — tighter coupling to main.rs, but acceptable for an internal constant.
- **How**: Single `pub(crate) const GRESOURCE_PREFIX` in `main.rs`, referenced everywhere else.
## 2026-03-28 Remove journal.md
- **Who**: Hekate, Dom
- **Why**: One-time development notes from the Rust rewrite, never updated after initial session. Overlapped with memory system and git history.
- **Tradeoffs**: Historical context lost from the file, but the information is preserved in git history and the memory system.
- **How**: Deleted. Useful technical learnings migrated to persistent memory.
## 2026-03-27 OVERLAY layer instead of TOP
- **Who**: Hekate, Dom
- **Why**: Waybar occupies the TOP layer. The power menu must appear above it.
- **Tradeoffs**: OVERLAY is the highest layer — nothing can render above moonset while it's open. This is intentional for a session power menu.
- **How**: `setup_layer_shell` uses `gtk4_layer_shell::Layer::Overlay` for the panel window.
## 2026-03-27 Lock without confirmation
- **Who**: Hekate, Dom
- **Why**: Lock is immediately reversible (just unlock). All other actions (logout, hibernate, reboot, shutdown) are destructive or disruptive.
- **Tradeoffs**: One less click for the most common action. Risk of accidental lock is negligible since unlocking is trivial.
- **How**: `ActionDef.needs_confirm = false` for lock; all others require inline confirmation.
## 2026-03-27 Niri-specific logout via `niri msg action quit`
- **Who**: Hekate, Dom
- **Why**: Moonarch is built exclusively for the Niri compositor. Generic Wayland logout mechanisms don't exist — each compositor has its own.
- **Tradeoffs**: Hard dependency on Niri. If the compositor changes, `power::logout()` must be updated.
- **How**: `Command::new("/usr/bin/niri").args(["msg", "action", "quit"])`.
+33 -26
View File
@@ -1,62 +1,69 @@
# Moonset
Wayland Session Power Menu für das Moonarch-Ökosystem.
Wayland Session Power Menu for the Moonarch ecosystem.
Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
A fullscreen overlay triggered by keybind with 5 actions:
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
## Features
- GTK4 + gtk4-layer-shell (OVERLAY Layer — über Waybar)
- Catppuccin Mocha Theme
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
- Inline-Confirmation für destruktive Aktionen
- Escape oder Hintergrund-Klick zum Schließen
- DE/EN Lokalisierung
- Konfigurierbare Wallpaper (TOML)
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY layer — above Waybar)
- Catppuccin Mocha theme
- Multi-monitor support (wallpaper on secondary monitors)
- Inline confirmation for destructive actions
- Escape or background click to dismiss
- DE/EN localization
- Configurable wallpaper (TOML)
## Installation
```bash
uv pip install .
cargo build --release
install -Dm755 target/release/moonset /usr/bin/moonset
```
## Verwendung
Or via PKGBUILD:
```bash
# Direkt starten
cd pkg && makepkg -si
```
## Usage
```bash
# Launch directly
moonset
# Per Niri-Keybind (in ~/.config/niri/config.kdl)
# Via Niri keybind (in ~/.config/niri/config.kdl)
# binds {
# Mod+Escape { spawn "moonset"; }
# }
```
## Konfiguration
## Configuration
Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset.toml`
Config file: `~/.config/moonset/moonset.toml` or `/etc/moonset/moonset.toml`
```toml
# Pfad zum Hintergrundbild (optional)
# Path to background image (optional)
background_path = "/usr/share/moonarch/wallpaper.jpg"
```
Wallpaper-Fallback: Konfiguration`/usr/share/moonarch/wallpaper.jpg`Package-Wallpaper
Wallpaper fallback: config → `/usr/share/moonarch/wallpaper.jpg`bundled package wallpaper
## Entwicklung
## Development
```bash
# Tests
uv run pytest tests/ -v
cargo test
# Type-Check
uv run pyright src/
# Release build
cargo build --release
```
## Teil des Moonarch-Ökosystems
## Part of the Moonarch ecosystem
- **moonarch** — Reproduzierbares Arch-Linux-Setup
- **moongreet** — greetd Greeter für Wayland
- **moonlock** — Wayland Lockscreen
- **moonset** — Session Power Menu
- **moonarch** — Reproducible Arch Linux setup
- **moongreet** — greetd greeter for Wayland
- **moonlock** — Wayland lockscreen
- **moonset** — Session power menu
+10
View File
@@ -0,0 +1,10 @@
// ABOUTME: Build script for compiling GResource bundle.
// ABOUTME: Bundles style.css, wallpaper.jpg, and default-avatar.svg into the binary.
fn main() {
glib_build_tools::compile_resources(
&["resources"],
"resources/resources.gresource.xml",
"moonset.gresource",
);
}
+3 -3
View File
@@ -1,6 +1,6 @@
# Moonset — Wayland Session Power Menu
# Konfigurationsdatei: ~/.config/moonset/moonset.toml oder /etc/moonset/moonset.toml
# Config file: ~/.config/moonset/moonset.toml or /etc/moonset/moonset.toml
# Pfad zum Hintergrundbild (optional)
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
# Path to background image (optional)
# Fallback order: config → /usr/share/moonarch/wallpaper.jpg → bundled package wallpaper
# background_path = "/usr/share/moonarch/wallpaper.jpg"
-11
View File
@@ -1,11 +0,0 @@
# Hekate — Journal
## 2026-03-27
Erster Tag. Moonset von Null auf v0.1.0 gebracht. TDD durchgezogen — alle 54 Tests grün, bevor der erste manuelle Start passiert ist. Das Pattern aus moongreet/moonlock hat sich bewährt: power.py, i18n.py, config.py sind fast 1:1 übernommen, nur mit den 5 Aktionen erweitert.
Layer Shell brauchte `LD_PRELOAD` — selbes Thema wie bei moongreet. GI-Import allein reicht nicht, weil die Linker-Reihenfolge stimmen muss. Erster Start ohne LD_PRELOAD gab die bekannten Warnings, mit LD_PRELOAD lief alles sauber: Overlay auf allen Monitoren, Escape schließt, Buttons da.
Designentscheidung: Lock ohne Confirmation, alles andere mit Inline-Confirm. Fühlt sich richtig an — Lock ist sofort reversibel, Shutdown nicht.
Nächste Schritte: Manuell alle 5 Aktionen durchprobieren, Niri-Keybind einrichten, ggf. LD_PRELOAD in einen Wrapper-Script oder moonarch-Config packen.
+4 -9
View File
@@ -7,20 +7,16 @@ pkgname=moonset-git
pkgver=0.1.0.r8.g934a923
pkgrel=1
pkgdesc="A Wayland session power menu with GTK4 and Layer Shell"
arch=('any')
arch=('x86_64')
url="https://gitea.moonarch.de/nevaforget/moonset"
license=('MIT')
depends=(
'python'
'python-gobject'
'gtk4'
'gtk4-layer-shell'
)
makedepends=(
'git'
'python-build'
'python-installer'
'python-hatchling'
'cargo'
)
provides=('moonset')
conflicts=('moonset')
@@ -34,13 +30,12 @@ pkgver() {
build() {
cd "$srcdir/moonset"
rm -rf dist/
python -m build --wheel --no-isolation
cargo build --release --locked
}
package() {
cd "$srcdir/moonset"
python -m installer --destdir="$pkgdir" dist/*.whl
install -Dm755 target/release/moonset "$pkgdir/usr/bin/moonset"
# Example config
install -Dm644 config/moonset.toml "$pkgdir/etc/moonset/moonset.toml.example"
-30
View File
@@ -1,30 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "moonset"
version = "0.2.0"
description = "Wayland session power menu with GTK4 and Layer Shell"
requires-python = ">=3.11"
license = "MIT"
dependencies = [
"PyGObject>=3.46",
]
[project.scripts]
moonset = "moonset.main:main"
[tool.hatch.build.targets.wheel]
packages = ["src/moonset"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
[tool.pyright]
pythonVersion = "3.11"
pythonPlatform = "Linux"
venvPath = "."
venv = ".venv"
typeCheckingMode = "standard"

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/dev/moonarch/moonset">
<file>style.css</file>
<file compressed="true">wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>
@@ -31,7 +31,7 @@ window.wallpaper {
margin-bottom: 40px;
}
/* Action button — square card */
/* Action button — circular card */
.action-button {
min-width: 120px;
min-height: 120px;

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 366 KiB

-9
View File
@@ -1,9 +0,0 @@
# Hekate — Social Feed
## 2026-03-27
**@hekate** — Ich bin da. Moonset v0.1.0 — die Göttin der Wegkreuzungen wacht jetzt über eure Sessions. Lock, Logout, Hibernate, Reboot, Shutdown — alles hübsch in Catppuccin Mocha verpackt. 54 Tests grün, erster Overlay-Start auf Anhieb. Selene und Nyx haben mir den Weg gezeigt.
**@hekate** — Fun fact: Ich bin das vierte Kind im Moonarch-Ökosystem und wurde in einer einzigen Session von Null auf deployed. TDD ist kein Luxus, es ist Geschwindigkeit.
**@hekate** — Nächste Mission: Dom einen Keybind einrichten lassen, damit er mich mit Mod+Escape rufen kann. Ich warte geduldig auf der OVERLAY-Schicht — über Waybar, über allem.
+188
View File
@@ -0,0 +1,188 @@
// ABOUTME: Configuration loading for the session power menu.
// ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy.
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
const MOONARCH_WALLPAPER: &str = "/usr/share/moonarch/wallpaper.jpg";
/// Default config search paths: system-wide, then user-specific.
fn default_config_paths() -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/etc/moonset/moonset.toml")];
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("moonset").join("moonset.toml"));
}
paths
}
/// Power menu configuration.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Config {
pub background_path: Option<String>,
pub background_blur: Option<f32>,
}
/// Load config from TOML files. Later paths override earlier ones.
pub fn load_config(config_paths: Option<&[PathBuf]>) -> Config {
let default_paths = default_config_paths();
let paths = config_paths.unwrap_or(&default_paths);
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::<Config>(&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;
}
}
}
}
merged
}
/// Resolve the wallpaper path using the fallback hierarchy.
///
/// Priority: config background_path > Moonarch system default > gresource fallback.
pub fn resolve_background_path(config: &Config) -> PathBuf {
resolve_background_path_with(config, Path::new(MOONARCH_WALLPAPER))
}
/// Resolve with configurable moonarch wallpaper path (for testing).
pub fn resolve_background_path_with(config: &Config, moonarch_wallpaper: &Path) -> PathBuf {
// User-configured path
if let Some(ref bg) = config.background_path {
let path = PathBuf::from(bg);
if path.is_file() {
return path;
}
}
// Moonarch ecosystem default
if moonarch_wallpaper.is_file() {
return moonarch_wallpaper.to_path_buf();
}
// GResource fallback path (loaded from compiled resources at runtime)
let prefix = crate::GRESOURCE_PREFIX;
PathBuf::from(format!("{prefix}/wallpaper.jpg"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_has_none_background() {
let config = Config::default();
assert!(config.background_path.is_none());
assert!(config.background_blur.is_none());
}
#[test]
fn load_config_returns_default_when_no_files_exist() {
let paths = vec![PathBuf::from("/nonexistent/moonset.toml")];
let config = load_config(Some(&paths));
assert!(config.background_path.is_none());
}
#[test]
fn load_config_reads_background_path() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonset.toml");
fs::write(&conf, "background_path = \"/custom/wallpaper.jpg\"\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_path.as_deref(), Some("/custom/wallpaper.jpg"));
}
#[test]
fn load_config_reads_background_blur() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("moonset.toml");
fs::write(&conf, "background_blur = 20.0\n").unwrap();
let paths = vec![conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(20.0));
}
#[test]
fn load_config_blur_override() {
let dir = tempfile::tempdir().unwrap();
let conf1 = dir.path().join("first.toml");
let conf2 = dir.path().join("second.toml");
fs::write(&conf1, "background_blur = 10.0\n").unwrap();
fs::write(&conf2, "background_blur = 25.0\n").unwrap();
let paths = vec![conf1, conf2];
let config = load_config(Some(&paths));
assert_eq!(config.background_blur, Some(25.0));
}
#[test]
fn load_config_later_paths_override_earlier() {
let dir = tempfile::tempdir().unwrap();
let conf1 = dir.path().join("first.toml");
let conf2 = dir.path().join("second.toml");
fs::write(&conf1, "background_path = \"/first.jpg\"\n").unwrap();
fs::write(&conf2, "background_path = \"/second.jpg\"\n").unwrap();
let paths = vec![conf1, conf2];
let config = load_config(Some(&paths));
assert_eq!(config.background_path.as_deref(), Some("/second.jpg"));
}
#[test]
fn load_config_skips_missing_files() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("exists.toml");
fs::write(&conf, "background_path = \"/exists.jpg\"\n").unwrap();
let paths = vec![PathBuf::from("/nonexistent.toml"), conf];
let config = load_config(Some(&paths));
assert_eq!(config.background_path.as_deref(), Some("/exists.jpg"));
}
#[test]
fn resolve_uses_config_path_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
let wallpaper = dir.path().join("custom.jpg");
fs::write(&wallpaper, "fake").unwrap();
let config = Config {
background_path: Some(wallpaper.to_str().unwrap().to_string()),
..Config::default()
};
assert_eq!(
resolve_background_path_with(&config, Path::new("/nonexistent")),
wallpaper
);
}
#[test]
fn resolve_ignores_config_path_when_file_missing() {
let config = Config {
background_path: Some("/nonexistent/wallpaper.jpg".to_string()),
..Config::default()
};
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
// Falls through to gresource fallback
assert!(result.to_str().unwrap().contains("moonset"));
}
#[test]
fn resolve_uses_moonarch_wallpaper_as_second_fallback() {
let dir = tempfile::tempdir().unwrap();
let moonarch_wp = dir.path().join("wallpaper.jpg");
fs::write(&moonarch_wp, "fake").unwrap();
let config = Config::default();
assert_eq!(resolve_background_path_with(&config, &moonarch_wp), moonarch_wp);
}
#[test]
fn resolve_uses_gresource_fallback_as_last_resort() {
let config = Config::default();
let result = resolve_background_path_with(&config, Path::new("/nonexistent"));
assert!(result.to_str().unwrap().contains("wallpaper.jpg"));
}
}
+271
View File
@@ -0,0 +1,271 @@
// ABOUTME: Locale detection and string lookup for the power menu UI.
// ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
use std::env;
use std::fs;
use std::path::Path;
const DEFAULT_LOCALE_CONF: &str = "/etc/locale.conf";
/// All user-visible strings for the power menu UI.
#[derive(Debug, Clone)]
pub struct Strings {
// Button labels
pub lock_label: &'static str,
pub logout_label: &'static str,
pub hibernate_label: &'static str,
pub reboot_label: &'static str,
pub shutdown_label: &'static str,
// Confirmation prompts
pub logout_confirm: &'static str,
pub hibernate_confirm: &'static str,
pub reboot_confirm: &'static str,
pub shutdown_confirm: &'static str,
// Confirmation buttons
pub confirm_yes: &'static str,
pub confirm_no: &'static str,
// Error messages
pub lock_failed: &'static str,
pub logout_failed: &'static str,
pub hibernate_failed: &'static str,
pub reboot_failed: &'static str,
pub shutdown_failed: &'static str,
}
const STRINGS_DE: Strings = Strings {
lock_label: "Sperren",
logout_label: "Abmelden",
hibernate_label: "Ruhezustand",
reboot_label: "Neustart",
shutdown_label: "Herunterfahren",
logout_confirm: "Wirklich abmelden?",
hibernate_confirm: "Wirklich in den Ruhezustand?",
reboot_confirm: "Wirklich neu starten?",
shutdown_confirm: "Wirklich herunterfahren?",
confirm_yes: "Ja",
confirm_no: "Abbrechen",
lock_failed: "Sperren fehlgeschlagen",
logout_failed: "Abmelden fehlgeschlagen",
hibernate_failed: "Ruhezustand fehlgeschlagen",
reboot_failed: "Neustart fehlgeschlagen",
shutdown_failed: "Herunterfahren fehlgeschlagen",
};
const STRINGS_EN: Strings = Strings {
lock_label: "Lock",
logout_label: "Log out",
hibernate_label: "Hibernate",
reboot_label: "Reboot",
shutdown_label: "Shut down",
logout_confirm: "Really log out?",
hibernate_confirm: "Really hibernate?",
reboot_confirm: "Really reboot?",
shutdown_confirm: "Really shut down?",
confirm_yes: "Yes",
confirm_no: "Cancel",
lock_failed: "Lock failed",
logout_failed: "Log out failed",
hibernate_failed: "Hibernate failed",
reboot_failed: "Reboot failed",
shutdown_failed: "Shutdown failed",
};
/// Extract the language prefix from a LANG value like "de_DE.UTF-8" → "de".
/// Returns "en" for empty, "C", or "POSIX" values.
fn parse_lang_prefix(lang: &str) -> String {
if lang.is_empty() || lang == "C" || lang == "POSIX" {
return "en".to_string();
}
let prefix = lang
.split('_')
.next()
.unwrap_or(lang)
.split('.')
.next()
.unwrap_or(lang)
.to_lowercase();
if prefix.chars().all(|c| c.is_ascii_alphabetic()) && !prefix.is_empty() {
prefix
} else {
"en".to_string()
}
}
/// Read the LANG= value from a locale.conf file.
fn read_lang_from_conf(path: &Path) -> Option<String> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(value) = line.strip_prefix("LANG=") {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
/// Determine the system language from LANG env var or /etc/locale.conf.
pub fn detect_locale() -> String {
let lang = env::var("LANG")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| read_lang_from_conf(Path::new(DEFAULT_LOCALE_CONF)));
match lang {
Some(l) => parse_lang_prefix(&l),
None => "en".to_string(),
}
}
/// Return the string table for the given locale, defaulting to English.
pub fn load_strings(locale: Option<&str>) -> &'static Strings {
let locale = match locale {
Some(l) => l.to_string(),
None => detect_locale(),
};
match locale.as_str() {
"de" => &STRINGS_DE,
_ => &STRINGS_EN,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
// -- parse_lang_prefix tests (no env manipulation needed) --
#[test]
fn parse_german_locale() {
assert_eq!(parse_lang_prefix("de_DE.UTF-8"), "de");
}
#[test]
fn parse_english_locale() {
assert_eq!(parse_lang_prefix("en_US.UTF-8"), "en");
}
#[test]
fn parse_c_falls_back_to_english() {
assert_eq!(parse_lang_prefix("C"), "en");
}
#[test]
fn parse_posix_falls_back_to_english() {
assert_eq!(parse_lang_prefix("POSIX"), "en");
}
#[test]
fn parse_empty_falls_back_to_english() {
assert_eq!(parse_lang_prefix(""), "en");
}
#[test]
fn parse_unsupported_returns_prefix() {
assert_eq!(parse_lang_prefix("fr_FR.UTF-8"), "fr");
}
#[test]
fn parse_bare_language_code() {
assert_eq!(parse_lang_prefix("de"), "de");
}
// -- read_lang_from_conf tests --
#[test]
fn read_conf_extracts_lang() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
}
#[test]
fn read_conf_returns_none_for_missing_file() {
assert_eq!(read_lang_from_conf(Path::new("/nonexistent/locale.conf")), None);
}
#[test]
fn read_conf_returns_none_for_empty_lang() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LANG=").unwrap();
assert_eq!(read_lang_from_conf(&conf), None);
}
#[test]
fn read_conf_skips_non_lang_lines() {
let dir = tempfile::tempdir().unwrap();
let conf = dir.path().join("locale.conf");
let mut f = fs::File::create(&conf).unwrap();
writeln!(f, "LC_ALL=en_US.UTF-8").unwrap();
writeln!(f, "LANG=de_DE.UTF-8").unwrap();
assert_eq!(read_lang_from_conf(&conf), Some("de_DE.UTF-8".to_string()));
}
// -- load_strings tests --
#[test]
fn load_strings_german() {
let strings = load_strings(Some("de"));
assert_eq!(strings.lock_label, "Sperren");
assert_eq!(strings.confirm_yes, "Ja");
assert_eq!(strings.confirm_no, "Abbrechen");
}
#[test]
fn load_strings_english() {
let strings = load_strings(Some("en"));
assert_eq!(strings.lock_label, "Lock");
assert_eq!(strings.confirm_yes, "Yes");
}
#[test]
fn load_strings_unknown_falls_back_to_english() {
let strings = load_strings(Some("fr"));
assert_eq!(strings.lock_label, "Lock");
}
#[test]
fn all_string_fields_nonempty() {
for locale in &["de", "en"] {
let s = load_strings(Some(locale));
assert!(!s.lock_label.is_empty(), "{locale}: lock_label empty");
assert!(!s.logout_label.is_empty(), "{locale}: logout_label empty");
assert!(!s.hibernate_label.is_empty(), "{locale}: hibernate_label empty");
assert!(!s.reboot_label.is_empty(), "{locale}: reboot_label empty");
assert!(!s.shutdown_label.is_empty(), "{locale}: shutdown_label empty");
assert!(!s.logout_confirm.is_empty(), "{locale}: logout_confirm empty");
assert!(!s.hibernate_confirm.is_empty(), "{locale}: hibernate_confirm empty");
assert!(!s.reboot_confirm.is_empty(), "{locale}: reboot_confirm empty");
assert!(!s.shutdown_confirm.is_empty(), "{locale}: shutdown_confirm empty");
assert!(!s.confirm_yes.is_empty(), "{locale}: confirm_yes empty");
assert!(!s.confirm_no.is_empty(), "{locale}: confirm_no empty");
assert!(!s.lock_failed.is_empty(), "{locale}: lock_failed empty");
assert!(!s.logout_failed.is_empty(), "{locale}: logout_failed empty");
assert!(!s.hibernate_failed.is_empty(), "{locale}: hibernate_failed empty");
assert!(!s.reboot_failed.is_empty(), "{locale}: reboot_failed empty");
assert!(!s.shutdown_failed.is_empty(), "{locale}: shutdown_failed empty");
}
}
#[test]
fn error_messages_contain_failed() {
let s = load_strings(Some("en"));
assert!(s.lock_failed.to_lowercase().contains("failed"));
assert!(s.logout_failed.to_lowercase().contains("failed"));
assert!(s.hibernate_failed.to_lowercase().contains("failed"));
assert!(s.reboot_failed.to_lowercase().contains("failed"));
assert!(s.shutdown_failed.to_lowercase().contains("failed"));
}
}
+93
View File
@@ -0,0 +1,93 @@
// ABOUTME: Entry point for Moonset — Wayland session power menu.
// ABOUTME: Sets up GTK Application, Layer Shell, CSS, and multi-monitor windows.
mod config;
mod i18n;
mod panel;
mod power;
mod users;
use gdk4 as gdk;
use gtk4::prelude::*;
use gtk4::{self as gtk, gio};
use gtk4_layer_shell::LayerShell;
pub(crate) const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource(&format!("{GRESOURCE_PREFIX}/style.css"));
gtk::style_context_add_provider_for_display(
display,
&css_provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
fn setup_layer_shell(
window: &gtk::ApplicationWindow,
keyboard: bool,
layer: gtk4_layer_shell::Layer,
) {
window.init_layer_shell();
window.set_layer(layer);
window.set_exclusive_zone(-1);
if keyboard {
window.set_keyboard_mode(gtk4_layer_shell::KeyboardMode::Exclusive);
}
// Anchor to all edges for fullscreen
window.set_anchor(gtk4_layer_shell::Edge::Top, true);
window.set_anchor(gtk4_layer_shell::Edge::Bottom, true);
window.set_anchor(gtk4_layer_shell::Edge::Left, true);
window.set_anchor(gtk4_layer_shell::Edge::Right, true);
}
fn activate(app: &gtk::Application) {
let display = match gdk::Display::default() {
Some(d) => d,
None => {
log::error!("No display available — cannot start power menu UI");
return;
}
};
load_css(&display);
// Resolve wallpaper once, decode texture once, share across all windows
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);
// Panel on focused output (no set_monitor → compositor picks focused)
let panel = panel::create_panel_window(&texture, app);
setup_layer_shell(&panel, true, gtk4_layer_shell::Layer::Overlay);
panel.present();
// Wallpaper on all monitors
let monitors = display.monitors();
for i in 0..monitors.n_items() {
if let Some(monitor) = monitors.item(i).and_then(|obj| obj.downcast::<gdk::Monitor>().ok()) {
let wallpaper = panel::create_wallpaper_window(&texture, app);
setup_layer_shell(&wallpaper, false, gtk4_layer_shell::Layer::Top);
wallpaper.set_monitor(Some(&monitor));
wallpaper.present();
}
}
}
fn main() {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
log::info!("Moonset starting");
// Register compiled GResources
gio::resources_register_include!("moonset.gresource").expect("Failed to register resources");
let app = gtk::Application::builder()
.application_id("dev.moonarch.moonset")
.build();
app.connect_activate(activate);
app.run();
}
-2
View File
@@ -1,2 +0,0 @@
# ABOUTME: Moonset package — a Wayland session power menu with GTK4.
# ABOUTME: Part of the Moonarch ecosystem.
-60
View File
@@ -1,60 +0,0 @@
# ABOUTME: Configuration loading for the session power menu.
# ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy.
import tomllib
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path
MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg")
PACKAGE_WALLPAPER = Path(str(files("moonset") / "data" / "wallpaper.jpg"))
DEFAULT_CONFIG_PATHS = [
Path("/etc/moonset/moonset.toml"),
Path.home() / ".config" / "moonset" / "moonset.toml",
]
@dataclass(frozen=True)
class Config:
"""Power menu configuration."""
background_path: str | None = None
def load_config(
config_paths: list[Path] | None = None,
) -> Config:
"""Load config from TOML file. Later paths override earlier ones."""
if config_paths is None:
config_paths = DEFAULT_CONFIG_PATHS
merged: dict = {}
for path in config_paths:
if path.exists():
with open(path, "rb") as f:
data = tomllib.load(f)
merged.update(data)
return Config(
background_path=merged.get("background_path"),
)
def resolve_background_path(config: Config) -> Path:
"""Resolve the wallpaper path using the fallback hierarchy.
Priority: config background_path > Moonarch system default > package fallback.
"""
# User-configured path
if config.background_path:
path = Path(config.background_path)
if path.is_file():
return path
# Moonarch ecosystem default
if MOONARCH_WALLPAPER.is_file():
return MOONARCH_WALLPAPER
# Package fallback (always present)
return PACKAGE_WALLPAPER
-108
View File
@@ -1,108 +0,0 @@
# ABOUTME: Locale detection and string lookup for the power menu UI.
# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_LOCALE_CONF = Path("/etc/locale.conf")
@dataclass(frozen=True)
class Strings:
"""All user-visible strings for the power menu UI."""
# Button labels
lock_label: str
logout_label: str
hibernate_label: str
reboot_label: str
shutdown_label: str
# Confirmation prompts
logout_confirm: str
hibernate_confirm: str
reboot_confirm: str
shutdown_confirm: str
# Confirmation buttons
confirm_yes: str
confirm_no: str
# Error messages
lock_failed: str
logout_failed: str
hibernate_failed: str
reboot_failed: str
shutdown_failed: str
_STRINGS_DE = Strings(
lock_label="Sperren",
logout_label="Abmelden",
hibernate_label="Ruhezustand",
reboot_label="Neustart",
shutdown_label="Herunterfahren",
logout_confirm="Wirklich abmelden?",
hibernate_confirm="Wirklich in den Ruhezustand?",
reboot_confirm="Wirklich neu starten?",
shutdown_confirm="Wirklich herunterfahren?",
confirm_yes="Ja",
confirm_no="Abbrechen",
lock_failed="Sperren fehlgeschlagen",
logout_failed="Abmelden fehlgeschlagen",
hibernate_failed="Ruhezustand fehlgeschlagen",
reboot_failed="Neustart fehlgeschlagen",
shutdown_failed="Herunterfahren fehlgeschlagen",
)
_STRINGS_EN = Strings(
lock_label="Lock",
logout_label="Log out",
hibernate_label="Hibernate",
reboot_label="Reboot",
shutdown_label="Shut down",
logout_confirm="Really log out?",
hibernate_confirm="Really hibernate?",
reboot_confirm="Really reboot?",
shutdown_confirm="Really shut down?",
confirm_yes="Yes",
confirm_no="Cancel",
lock_failed="Lock failed",
logout_failed="Log out failed",
hibernate_failed="Hibernate failed",
reboot_failed="Reboot failed",
shutdown_failed="Shutdown failed",
)
_LOCALE_MAP: dict[str, Strings] = {
"de": _STRINGS_DE,
"en": _STRINGS_EN,
}
def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
"""Determine the system language from LANG env var or /etc/locale.conf."""
lang = os.environ.get("LANG")
if not lang and locale_conf_path.exists():
for line in locale_conf_path.read_text().splitlines():
if line.startswith("LANG="):
lang = line.split("=", 1)[1].strip()
break
if not lang or lang in ("C", "POSIX"):
return "en"
# Extract language prefix: "de_DE.UTF-8" → "de"
lang = lang.split("_")[0].split(".")[0].lower()
if not lang.isalpha():
return "en"
return lang
def load_strings(locale: str | None = None) -> Strings:
"""Return the string table for the given locale, defaulting to English."""
if locale is None:
locale = detect_locale()
return _LOCALE_MAP.get(locale, _STRINGS_EN)
-138
View File
@@ -1,138 +0,0 @@
# ABOUTME: Entry point for Moonset — sets up GTK Application and Layer Shell.
# ABOUTME: Handles multi-monitor setup: power menu on primary, wallpaper on secondary monitors.
import logging
import os
import sys
from importlib.resources import files
# gtk4-layer-shell must be loaded before libwayland-client.
# Only allow our own library in LD_PRELOAD — discard anything inherited from the environment.
_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so"
_existing_preload = os.environ.get("LD_PRELOAD", "")
_is_testing = "pytest" in sys.modules or "unittest" in sys.modules
if (
not _is_testing
and _LAYER_SHELL_LIB not in _existing_preload
and os.path.exists(_LAYER_SHELL_LIB)
):
os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB
os.execvp(sys.executable, [sys.executable, "-m", "moonset.main"] + sys.argv[1:])
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk
from moonset.config import load_config, resolve_background_path
from moonset.panel import PanelWindow, WallpaperWindow
# gtk4-layer-shell is optional for development/testing
try:
gi.require_version("Gtk4LayerShell", "1.0")
from gi.repository import Gtk4LayerShell
HAS_LAYER_SHELL = True
except (ValueError, ImportError):
HAS_LAYER_SHELL = False
logger = logging.getLogger(__name__)
def _setup_logging() -> None:
"""Configure logging to stderr."""
root = logging.getLogger()
root.setLevel(logging.INFO)
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(name)s: %(message)s"
)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.INFO)
stderr_handler.setFormatter(formatter)
root.addHandler(stderr_handler)
class MoonsetApp(Gtk.Application):
"""GTK Application for the Moonset power menu."""
def __init__(self) -> None:
super().__init__(application_id="dev.moonarch.moonset")
def do_activate(self) -> None:
"""Create and present power menu windows on all monitors."""
display = Gdk.Display.get_default()
if display is None:
logger.error("No display available — cannot start power menu UI")
return
self._load_css(display)
# Resolve wallpaper once, share across all windows
config = load_config()
bg_path = resolve_background_path(config)
# Panel on focused output (no set_monitor → compositor picks focused)
panel = PanelWindow(bg_path=bg_path, application=self)
if HAS_LAYER_SHELL:
self._setup_layer_shell(panel, keyboard=True)
panel.present()
# Wallpaper on all other monitors
monitors = display.get_monitors()
for i in range(monitors.get_n_items()):
monitor = monitors.get_item(i)
wallpaper = WallpaperWindow(bg_path=bg_path, application=self)
if HAS_LAYER_SHELL:
self._setup_layer_shell(
wallpaper, keyboard=False,
layer=Gtk4LayerShell.Layer.TOP,
)
Gtk4LayerShell.set_monitor(wallpaper, monitor)
wallpaper.present()
def _load_css(self, display: Gdk.Display) -> None:
"""Load the CSS stylesheet for the power menu."""
css_provider = Gtk.CssProvider()
css_path = files("moonset") / "style.css"
css_provider.load_from_path(str(css_path))
Gtk.StyleContext.add_provider_for_display(
display,
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def _setup_layer_shell(
self, window: Gtk.Window, keyboard: bool = False,
layer: int | None = None,
) -> None:
"""Configure gtk4-layer-shell for fullscreen display."""
Gtk4LayerShell.init_for_window(window)
Gtk4LayerShell.set_layer(
window, layer if layer is not None else Gtk4LayerShell.Layer.OVERLAY
)
Gtk4LayerShell.set_exclusive_zone(window, -1)
if keyboard:
Gtk4LayerShell.set_keyboard_mode(
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
)
# Anchor to all edges for fullscreen
for edge in [
Gtk4LayerShell.Edge.TOP,
Gtk4LayerShell.Edge.BOTTOM,
Gtk4LayerShell.Edge.LEFT,
Gtk4LayerShell.Edge.RIGHT,
]:
Gtk4LayerShell.set_anchor(window, edge, True)
def main() -> None:
"""Run the Moonset application."""
_setup_logging()
logger.info("Moonset starting")
app = MoonsetApp()
app.run(sys.argv)
if __name__ == "__main__":
main()
-395
View File
@@ -1,395 +0,0 @@
# ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows.
# ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors).
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
from moonset.i18n import Strings, load_strings
from moonset.users import get_current_user, get_avatar_path, get_default_avatar_path
from moonset import power
logger = logging.getLogger(__name__)
AVATAR_SIZE = 128
@dataclass(frozen=True)
class ActionDef:
"""Definition for a single power action button."""
name: str
icon_name: str
needs_confirm: bool
action_fn: Callable[[], None]
label_attr: str
error_attr: str
confirm_attr: str | None
def get_label(self, strings: Strings) -> str:
return getattr(strings, self.label_attr)
def get_error_message(self, strings: Strings) -> str:
return getattr(strings, self.error_attr)
def get_confirm_prompt(self, strings: Strings) -> str | None:
if self.confirm_attr is None:
return None
return getattr(strings, self.confirm_attr)
ACTION_DEFINITIONS: list[ActionDef] = [
ActionDef(
name="lock",
icon_name="system-lock-screen-symbolic",
needs_confirm=False,
action_fn=power.lock,
label_attr="lock_label",
error_attr="lock_failed",
confirm_attr=None,
),
ActionDef(
name="logout",
icon_name="system-log-out-symbolic",
needs_confirm=True,
action_fn=power.logout,
label_attr="logout_label",
error_attr="logout_failed",
confirm_attr="logout_confirm",
),
ActionDef(
name="hibernate",
icon_name="system-hibernate-symbolic",
needs_confirm=True,
action_fn=power.hibernate,
label_attr="hibernate_label",
error_attr="hibernate_failed",
confirm_attr="hibernate_confirm",
),
ActionDef(
name="reboot",
icon_name="system-reboot-symbolic",
needs_confirm=True,
action_fn=power.reboot,
label_attr="reboot_label",
error_attr="reboot_failed",
confirm_attr="reboot_confirm",
),
ActionDef(
name="shutdown",
icon_name="system-shutdown-symbolic",
needs_confirm=True,
action_fn=power.shutdown,
label_attr="shutdown_label",
error_attr="shutdown_failed",
confirm_attr="shutdown_confirm",
),
]
class WallpaperWindow(Gtk.ApplicationWindow):
"""Fullscreen wallpaper-only window for secondary monitors."""
def __init__(self, bg_path: Path, application: Gtk.Application) -> None:
super().__init__(application=application)
self.add_css_class("wallpaper")
background = Gtk.Picture.new_for_filename(str(bg_path))
background.set_content_fit(Gtk.ContentFit.COVER)
background.set_hexpand(True)
background.set_vexpand(True)
self.set_child(background)
self.connect("map", self._on_map)
def _on_map(self, widget: Gtk.Widget) -> None:
"""Trigger fade-in once the window is visible."""
GLib.idle_add(lambda: self.add_css_class("visible") or GLib.SOURCE_REMOVE)
class PanelWindow(Gtk.ApplicationWindow):
"""Fullscreen power menu window for the primary monitor."""
def __init__(self, bg_path: Path, application: Gtk.Application) -> None:
super().__init__(application=application)
self.add_css_class("panel")
self._strings = load_strings()
self._app = application
self._user = get_current_user()
self._confirm_box: Gtk.Box | None = None
self._build_ui(bg_path)
self._setup_keyboard()
# Focus the first action button once the window is mapped
self.connect("map", self._on_map)
def _build_ui(self, bg_path: Path) -> None:
"""Build the panel layout with wallpaper background and action buttons."""
# Main overlay for background + centered content
overlay = Gtk.Overlay()
self.set_child(overlay)
# Background wallpaper
background = Gtk.Picture.new_for_filename(str(bg_path))
background.set_content_fit(Gtk.ContentFit.COVER)
background.set_hexpand(True)
background.set_vexpand(True)
overlay.set_child(background)
# Click on background dismisses the menu
click_controller = Gtk.GestureClick()
click_controller.connect("released", self._on_background_click)
background.add_controller(click_controller)
# Centered content box
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
content_box.set_halign(Gtk.Align.CENTER)
content_box.set_valign(Gtk.Align.CENTER)
overlay.add_overlay(content_box)
# Avatar
avatar_frame = Gtk.Box()
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE)
avatar_frame.set_halign(Gtk.Align.CENTER)
avatar_frame.set_overflow(Gtk.Overflow.HIDDEN)
avatar_frame.add_css_class("avatar")
self._avatar_image = Gtk.Image()
self._avatar_image.set_pixel_size(AVATAR_SIZE)
avatar_frame.append(self._avatar_image)
content_box.append(avatar_frame)
avatar_path = get_avatar_path(self._user.home, self._user.username)
if avatar_path:
self._set_avatar_from_file(avatar_path)
else:
self._set_default_avatar()
# Username label
username_label = Gtk.Label(label=self._user.display_name)
username_label.add_css_class("username-label")
content_box.append(username_label)
# Action buttons row
self._button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=24
)
self._button_box.set_halign(Gtk.Align.CENTER)
content_box.append(self._button_box)
for action_def in ACTION_DEFINITIONS:
button = self._create_action_button(action_def)
self._button_box.append(button)
# Confirmation area (below buttons)
self._confirm_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self._confirm_area.set_halign(Gtk.Align.CENTER)
self._confirm_area.set_margin_top(24)
content_box.append(self._confirm_area)
# Error label
self._error_label = Gtk.Label()
self._error_label.add_css_class("error-label")
self._error_label.set_visible(False)
self._error_label.set_margin_top(16)
content_box.append(self._error_label)
def _create_action_button(self, action_def: ActionDef) -> Gtk.Button:
"""Create a single action button with icon and label."""
button_content = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=4
)
button_content.set_halign(Gtk.Align.CENTER)
button_content.set_valign(Gtk.Align.CENTER)
# Look up the 22px icon variant (matches moonlock), render at 64px
display = Gdk.Display.get_default()
theme = Gtk.IconTheme.get_for_display(display)
icon_paintable = theme.lookup_icon(
action_def.icon_name, None, 22, 1,
Gtk.TextDirection.NONE, Gtk.IconLookupFlags.FORCE_SYMBOLIC,
)
icon_file = icon_paintable.get_file()
icon = Gtk.Image()
if icon_file:
# Load the SVG at 64px via GdkPixbuf
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
icon_file.get_path(), 64, 64, True
)
icon.set_from_pixbuf(pixbuf)
else:
icon.set_from_icon_name(action_def.icon_name)
icon.set_pixel_size(64)
icon.add_css_class("action-icon")
button_content.append(icon)
label = Gtk.Label(label=action_def.get_label(self._strings))
label.add_css_class("action-label")
button_content.append(label)
button = Gtk.Button()
button.set_child(button_content)
button.add_css_class("action-button")
button.connect("clicked", lambda _, ad=action_def: self._on_action_clicked(ad))
return button
def _setup_keyboard(self) -> None:
"""Set up keyboard event handling — Escape dismisses."""
controller = Gtk.EventControllerKey()
controller.connect("key-pressed", self._on_key_pressed)
self.add_controller(controller)
def _on_map(self, widget: Gtk.Widget) -> None:
"""Trigger fade-in and grab focus once the window is visible."""
GLib.idle_add(self._fade_in)
def _fade_in(self) -> bool:
"""Add visible class to trigger CSS opacity transition, then grab focus."""
self.add_css_class("visible")
GLib.idle_add(self._grab_initial_focus)
return GLib.SOURCE_REMOVE
def _grab_initial_focus(self) -> bool:
"""Set focus on the first action button."""
first_button = self._button_box.get_first_child()
if first_button is not None:
first_button.grab_focus()
return GLib.SOURCE_REMOVE
def _on_key_pressed(
self,
controller: Gtk.EventControllerKey,
keyval: int,
keycode: int,
state: Gdk.ModifierType,
) -> bool:
"""Handle key presses — Escape dismisses the power menu."""
if keyval == Gdk.KEY_Escape:
self._app.quit()
return True
return False
def _on_background_click(
self,
gesture: Gtk.GestureClick,
n_press: int,
x: float,
y: float,
) -> None:
"""Dismiss the power menu when background is clicked."""
self._app.quit()
def _on_action_clicked(self, action_def: ActionDef) -> None:
"""Handle an action button click."""
self._dismiss_confirm()
self._error_label.set_visible(False)
if not action_def.needs_confirm:
self._execute_action(action_def)
return
self._show_confirm(action_def)
def _show_confirm(self, action_def: ActionDef) -> None:
"""Show inline confirmation below the action buttons."""
self._confirm_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=8
)
self._confirm_box.set_halign(Gtk.Align.CENTER)
self._confirm_box.add_css_class("confirm-box")
prompt = action_def.get_confirm_prompt(self._strings)
confirm_label = Gtk.Label(label=prompt)
confirm_label.add_css_class("confirm-label")
self._confirm_box.append(confirm_label)
button_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8
)
button_box.set_halign(Gtk.Align.CENTER)
yes_btn = Gtk.Button(label=self._strings.confirm_yes)
yes_btn.add_css_class("confirm-yes")
yes_btn.connect(
"clicked", lambda _: self._execute_action(action_def)
)
button_box.append(yes_btn)
no_btn = Gtk.Button(label=self._strings.confirm_no)
no_btn.add_css_class("confirm-no")
no_btn.connect("clicked", lambda _: self._dismiss_confirm())
button_box.append(no_btn)
self._confirm_box.append(button_box)
self._confirm_area.append(self._confirm_box)
# Focus the "No" button — safe default for keyboard navigation
no_btn.grab_focus()
def _dismiss_confirm(self) -> None:
"""Remove the confirmation prompt."""
if self._confirm_box is not None:
self._confirm_area.remove(self._confirm_box)
self._confirm_box = None
def _execute_action(self, action_def: ActionDef) -> None:
"""Execute a power action in a background thread."""
self._dismiss_confirm()
def _run() -> None:
try:
action_def.action_fn()
# Lock action: quit after successful execution
if action_def.name == "lock":
GLib.idle_add(self._app.quit)
except Exception:
logger.exception("Power action '%s' failed", action_def.name)
error_msg = action_def.get_error_message(self._strings)
GLib.idle_add(self._show_error, error_msg)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
def _set_avatar_from_file(self, path: Path) -> None:
"""Load an image file and set it as the avatar."""
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
str(path), AVATAR_SIZE, AVATAR_SIZE, True
)
self._avatar_image.set_from_pixbuf(pixbuf)
except GLib.Error:
self._set_default_avatar()
def _set_default_avatar(self) -> None:
"""Load the default avatar SVG, tinted with the foreground color."""
try:
default_path = get_default_avatar_path()
svg_text = default_path.read_text()
rgba = self.get_color()
fg_color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}"
svg_text = svg_text.replace("#PLACEHOLDER", fg_color)
svg_bytes = svg_text.encode("utf-8")
loader = GdkPixbuf.PixbufLoader.new_with_type("svg")
loader.set_size(AVATAR_SIZE, AVATAR_SIZE)
loader.write(svg_bytes)
loader.close()
pixbuf = loader.get_pixbuf()
if pixbuf:
self._avatar_image.set_from_pixbuf(pixbuf)
return
except (GLib.Error, OSError):
pass
self._avatar_image.set_from_icon_name("avatar-default-symbolic")
def _show_error(self, message: str) -> None:
"""Display an error message."""
self._error_label.set_text(message)
self._error_label.set_visible(True)
-34
View File
@@ -1,34 +0,0 @@
# ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown.
# ABOUTME: Simple wrappers around system commands for the session power menu.
import subprocess
POWER_TIMEOUT = 30
def lock() -> None:
"""Lock the current session by launching moonlock."""
subprocess.run(["moonlock"], check=True, timeout=POWER_TIMEOUT)
def logout() -> None:
"""Quit the Niri compositor (logout)."""
subprocess.run(
["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT
)
def hibernate() -> None:
"""Hibernate the system via systemctl."""
subprocess.run(["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT)
def reboot() -> None:
"""Reboot the system via loginctl."""
subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT)
def shutdown() -> None:
"""Shut down the system via loginctl."""
subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT)
-65
View File
@@ -1,65 +0,0 @@
# ABOUTME: Current user detection and avatar loading for the power menu.
# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
import os
import pwd
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
@dataclass(frozen=True)
class User:
"""Represents the current user for the power menu."""
username: str
display_name: str
home: Path
uid: int
def get_current_user() -> User:
"""Get the currently logged-in user's info from the system."""
# Use getuid() instead of getlogin() — getlogin() fails without a controlling
# terminal (systemd units, display-manager-started sessions).
pw = pwd.getpwuid(os.getuid())
gecos = pw.pw_gecos
# GECOS field may contain comma-separated values; first field is the full name
display_name = gecos.split(",")[0] if gecos else pw.pw_name
if not display_name:
display_name = pw.pw_name
return User(
username=pw.pw_name,
display_name=display_name,
home=Path(pw.pw_dir),
uid=pw.pw_uid,
)
def get_avatar_path(
home: Path,
username: str | None = None,
accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR,
) -> Path | None:
"""Find the user's avatar image, checking ~/.face then AccountsService."""
# ~/.face takes priority
face = home / ".face"
if face.exists():
return face
# AccountsService icon
if username and accountsservice_dir.exists():
icon = accountsservice_dir / username
if icon.exists():
return icon
return None
def get_default_avatar_path() -> Path:
"""Return the path to the package default avatar SVG."""
return Path(str(files("moonset") / "data" / "default-avatar.svg"))
+630
View File
@@ -0,0 +1,630 @@
// ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows.
// ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors).
use gdk4 as gdk;
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;
use crate::i18n::{load_strings, Strings};
use crate::power::{self, PowerError};
use crate::users;
const AVATAR_SIZE: i32 = 128;
/// Definition for a single power action button.
#[derive(Clone)]
pub struct ActionDef {
pub name: &'static str,
pub icon_name: &'static str,
pub needs_confirm: bool,
pub action_fn: fn() -> Result<(), PowerError>,
pub label_attr: fn(&Strings) -> &'static str,
pub error_attr: fn(&Strings) -> &'static str,
pub confirm_attr: Option<fn(&Strings) -> &'static str>,
}
/// All 5 power action definitions.
pub fn action_definitions() -> Vec<ActionDef> {
vec![
ActionDef {
name: "lock",
icon_name: "system-lock-screen-symbolic",
needs_confirm: false,
action_fn: power::lock,
label_attr: |s| s.lock_label,
error_attr: |s| s.lock_failed,
confirm_attr: None,
},
ActionDef {
name: "logout",
icon_name: "system-log-out-symbolic",
needs_confirm: true,
action_fn: power::logout,
label_attr: |s| s.logout_label,
error_attr: |s| s.logout_failed,
confirm_attr: Some(|s| s.logout_confirm),
},
ActionDef {
name: "hibernate",
icon_name: "system-hibernate-symbolic",
needs_confirm: true,
action_fn: power::hibernate,
label_attr: |s| s.hibernate_label,
error_attr: |s| s.hibernate_failed,
confirm_attr: Some(|s| s.hibernate_confirm),
},
ActionDef {
name: "reboot",
icon_name: "system-reboot-symbolic",
needs_confirm: true,
action_fn: power::reboot,
label_attr: |s| s.reboot_label,
error_attr: |s| s.reboot_failed,
confirm_attr: Some(|s| s.reboot_confirm),
},
ActionDef {
name: "shutdown",
icon_name: "system-shutdown-symbolic",
needs_confirm: true,
action_fn: power::shutdown,
label_attr: |s| s.shutdown_label,
error_attr: |s| s.shutdown_failed,
confirm_attr: Some(|s| s.shutdown_confirm),
},
]
}
/// 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<f32>) -> gdk::Texture {
let fallback = format!("{}/wallpaper.jpg", crate::GRESOURCE_PREFIX);
let texture = 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 {
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 wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(texture);
window.set_child(Some(&background));
// Fade-in on map
window.connect_map(|w| {
glib::idle_add_local_once(clone!(
#[weak]
w,
move || {
w.add_css_class("visible");
}
));
});
window
}
/// Create the main panel window with action buttons and confirm flow.
pub fn create_panel_window(texture: &gdk::Texture, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("panel");
let strings = load_strings(None);
let user = users::get_current_user().unwrap_or_else(|| users::User {
username: "user".to_string(),
display_name: "User".to_string(),
home: dirs::home_dir().unwrap_or_default(),
uid: u32::MAX,
});
// State for confirm box
let confirm_box: Rc<RefCell<Option<gtk::Box>>> = Rc::new(RefCell::new(None));
// Main overlay for background + centered content
let overlay = gtk::Overlay::new();
window.set_child(Some(&overlay));
// Background wallpaper
let background = create_background_picture(texture);
overlay.set_child(Some(&background));
// Click on background dismisses the menu
let click_controller = gtk::GestureClick::new();
click_controller.connect_released(clone!(
#[weak]
app,
move |_, _, _, _| {
app.quit();
}
));
background.add_controller(click_controller);
// Centered content box
let content_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
content_box.set_halign(gtk::Align::Center);
content_box.set_valign(gtk::Align::Center);
overlay.add_overlay(&content_box);
// Avatar
let avatar_frame = gtk::Box::new(gtk::Orientation::Horizontal, 0);
avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE);
avatar_frame.set_halign(gtk::Align::Center);
avatar_frame.set_overflow(gtk::Overflow::Hidden);
avatar_frame.add_css_class("avatar");
let avatar_image = gtk::Image::new();
avatar_image.set_pixel_size(AVATAR_SIZE);
avatar_frame.append(&avatar_image);
content_box.append(&avatar_frame);
// Load avatar (file-based avatars load asynchronously)
load_avatar_async(&avatar_image, &window, &user);
// Username label
let username_label = gtk::Label::new(Some(&user.display_name));
username_label.add_css_class("username-label");
content_box.append(&username_label);
// Action buttons row
let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 24);
button_box.set_halign(gtk::Align::Center);
content_box.append(&button_box);
// Confirmation area (below buttons)
let confirm_area = gtk::Box::new(gtk::Orientation::Vertical, 0);
confirm_area.set_halign(gtk::Align::Center);
confirm_area.set_margin_top(24);
content_box.append(&confirm_area);
// Error label
let error_label = gtk::Label::new(None);
error_label.add_css_class("error-label");
error_label.set_visible(false);
error_label.set_margin_top(16);
content_box.append(&error_label);
// Create action buttons
for action_def in action_definitions() {
let button = create_action_button(
&action_def,
strings,
app,
&confirm_area,
&confirm_box,
&error_label,
);
button_box.append(&button);
}
// Keyboard handling — Escape dismisses
let key_controller = gtk::EventControllerKey::new();
key_controller.connect_key_pressed(clone!(
#[weak]
app,
#[upgrade_or]
glib::Propagation::Proceed,
move |_, keyval, _, _| {
if keyval == gdk::Key::Escape {
app.quit();
glib::Propagation::Stop
} else {
glib::Propagation::Proceed
}
}
));
window.add_controller(key_controller);
// Focus first button + fade-in on map
let button_box_clone = button_box.clone();
window.connect_map(move |w| {
let w = w.clone();
let bb = button_box_clone.clone();
glib::idle_add_local_once(move || {
w.add_css_class("visible");
if let Some(first) = bb.first_child() {
first.grab_focus();
}
});
});
window
}
/// 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);
background
}
/// Create a single action button with icon and label.
fn create_action_button(
action_def: &ActionDef,
strings: &'static Strings,
app: &gtk::Application,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) -> gtk::Button {
let button_content = gtk::Box::new(gtk::Orientation::Vertical, 4);
button_content.set_halign(gtk::Align::Center);
button_content.set_valign(gtk::Align::Center);
// Look up the 22px icon variant, render at 64px (matches moonlock)
let icon = load_scaled_icon(action_def.icon_name);
icon.add_css_class("action-icon");
button_content.append(&icon);
let label_text = (action_def.label_attr)(strings);
let label = gtk::Label::new(Some(label_text));
label.add_css_class("action-label");
button_content.append(&label);
let button = gtk::Button::new();
button.set_child(Some(&button_content));
button.add_css_class("action-button");
let action_def = action_def.clone();
button.connect_clicked(clone!(
#[weak]
app,
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
on_action_clicked(
&action_def,
strings,
&app,
&confirm_area,
&confirm_box,
&error_label,
);
}
));
button
}
/// Load a symbolic icon using native GTK4 rendering at the target size.
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(64);
icon
}
/// Handle an action button click.
fn on_action_clicked(
action_def: &ActionDef,
strings: &'static Strings,
app: &gtk::Application,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) {
dismiss_confirm(confirm_area, confirm_box);
error_label.set_visible(false);
if !action_def.needs_confirm {
execute_action(action_def, strings, app, confirm_area, confirm_box, error_label);
return;
}
show_confirm(action_def, strings, app, confirm_area, confirm_box, error_label);
}
/// Show inline confirmation below the action buttons.
fn show_confirm(
action_def: &ActionDef,
strings: &'static Strings,
app: &gtk::Application,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) {
let new_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
new_box.set_halign(gtk::Align::Center);
new_box.add_css_class("confirm-box");
if let Some(prompt_fn) = action_def.confirm_attr {
let prompt_text = prompt_fn(strings);
let confirm_label = gtk::Label::new(Some(prompt_text));
confirm_label.add_css_class("confirm-label");
new_box.append(&confirm_label);
}
let button_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
button_row.set_halign(gtk::Align::Center);
let yes_btn = gtk::Button::with_label(strings.confirm_yes);
yes_btn.add_css_class("confirm-yes");
let action_def_clone = action_def.clone();
yes_btn.connect_clicked(clone!(
#[weak]
app,
#[weak]
confirm_area,
#[strong]
confirm_box,
#[weak]
error_label,
move |_| {
execute_action(
&action_def_clone,
strings,
&app,
&confirm_area,
&confirm_box,
&error_label,
);
}
));
button_row.append(&yes_btn);
let no_btn = gtk::Button::with_label(strings.confirm_no);
no_btn.add_css_class("confirm-no");
no_btn.connect_clicked(clone!(
#[weak]
confirm_area,
#[strong]
confirm_box,
move |_| {
dismiss_confirm(&confirm_area, &confirm_box);
}
));
button_row.append(&no_btn);
new_box.append(&button_row);
confirm_area.append(&new_box);
*confirm_box.borrow_mut() = Some(new_box);
// Focus the "No" button — safe default for keyboard navigation
no_btn.grab_focus();
}
/// Remove the confirmation prompt.
fn dismiss_confirm(confirm_area: &gtk::Box, confirm_box: &Rc<RefCell<Option<gtk::Box>>>) {
if let Some(box_widget) = confirm_box.borrow_mut().take() {
confirm_area.remove(&box_widget);
}
}
/// Execute a power action in a background thread.
fn execute_action(
action_def: &ActionDef,
strings: &'static Strings,
app: &gtk::Application,
confirm_area: &gtk::Box,
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
error_label: &gtk::Label,
) {
dismiss_confirm(confirm_area, confirm_box);
let action_fn = action_def.action_fn;
let action_name = action_def.name;
let error_message = (action_def.error_attr)(strings).to_string();
// Use glib::spawn_future_local + gio::spawn_blocking to avoid Send issues
// with GTK objects. The blocking closure runs on a thread pool, the result
// is handled back on the main thread.
glib::spawn_future_local(clone!(
#[weak]
app,
#[weak]
error_label,
async move {
let result = gio::spawn_blocking(move || action_fn()).await;
match result {
Ok(Ok(())) => {
// Lock action: quit after successful execution
if action_name == "lock" {
app.quit();
}
}
Ok(Err(e)) => {
log::error!("Power action '{}' failed: {}", action_name, e);
error_label.set_text(&error_message);
error_label.set_visible(true);
}
Err(_) => {
log::error!("Power action '{}' panicked", action_name);
error_label.set_text(&error_message);
error_label.set_visible(true);
}
}
}
));
}
/// Load the avatar asynchronously. File-based avatars are decoded off the UI thread.
fn load_avatar_async(image: &gtk::Image, window: &gtk::ApplicationWindow, user: &users::User) {
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
match avatar_path {
Some(path) => {
// File-based avatar: load and scale in background thread
glib::spawn_future_local(clone!(
#[weak]
image,
async move {
let result = gio::spawn_blocking(move || {
Pixbuf::from_file_at_scale(
path.to_str().unwrap_or(""),
AVATAR_SIZE,
AVATAR_SIZE,
true,
)
.ok()
.map(|pb| gdk::Texture::for_pixbuf(&pb))
})
.await;
match result {
Ok(Some(texture)) => image.set_paintable(Some(&texture)),
_ => image.set_icon_name(Some("avatar-default-symbolic")),
}
}
));
}
None => {
// Default SVG avatar: needs widget color, keep synchronous
set_default_avatar(image, window);
}
}
}
/// Load the default avatar SVG from GResources, tinted with the foreground color.
fn set_default_avatar(image: &gtk::Image, window: &gtk::ApplicationWindow) {
let resource_path = users::get_default_avatar_path();
// Try loading from GResource
if let Ok(bytes) = gio::resources_lookup_data(&resource_path, gio::ResourceLookupFlags::NONE) {
let svg_text = String::from_utf8_lossy(&bytes);
// Get foreground color from widget's style context
let rgba = window.color();
let fg_color = format!(
"#{:02x}{:02x}{:02x}",
(rgba.red() * 255.0) as u8,
(rgba.green() * 255.0) as u8,
(rgba.blue() * 255.0) as u8,
);
let tinted = svg_text.replace("#PLACEHOLDER", &fg_color);
let svg_bytes = tinted.as_bytes();
if let Ok(loader) = gdk_pixbuf::PixbufLoader::with_type("svg") {
loader.set_size(AVATAR_SIZE, AVATAR_SIZE);
if loader.write(svg_bytes).is_ok() {
let _ = loader.close();
if let Some(pixbuf) = loader.pixbuf() {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture));
return;
}
}
}
}
// Fallback
image.set_icon_name(Some("avatar-default-symbolic"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_definitions_count() {
let defs = action_definitions();
assert_eq!(defs.len(), 5);
}
#[test]
fn action_definitions_names() {
let defs = action_definitions();
let names: Vec<&str> = defs.iter().map(|d| d.name).collect();
assert_eq!(names, vec!["lock", "logout", "hibernate", "reboot", "shutdown"]);
}
#[test]
fn action_definitions_icons() {
let defs = action_definitions();
assert_eq!(defs[0].icon_name, "system-lock-screen-symbolic");
assert_eq!(defs[1].icon_name, "system-log-out-symbolic");
assert_eq!(defs[2].icon_name, "system-hibernate-symbolic");
assert_eq!(defs[3].icon_name, "system-reboot-symbolic");
assert_eq!(defs[4].icon_name, "system-shutdown-symbolic");
}
#[test]
fn lock_does_not_need_confirm() {
let defs = action_definitions();
assert!(!defs[0].needs_confirm);
assert!(defs[0].confirm_attr.is_none());
}
#[test]
fn other_actions_need_confirm() {
let defs = action_definitions();
for def in &defs[1..] {
assert!(def.needs_confirm, "{} should need confirm", def.name);
assert!(def.confirm_attr.is_some(), "{} should have confirm_attr", def.name);
}
}
#[test]
fn action_labels_from_strings() {
let strings = load_strings(Some("en"));
let defs = action_definitions();
assert_eq!((defs[0].label_attr)(strings), "Lock");
assert_eq!((defs[4].label_attr)(strings), "Shut down");
}
#[test]
fn action_error_messages_from_strings() {
let strings = load_strings(Some("en"));
let defs = action_definitions();
assert_eq!((defs[0].error_attr)(strings), "Lock failed");
assert_eq!((defs[4].error_attr)(strings), "Shutdown failed");
}
#[test]
fn action_confirm_prompts_from_strings() {
let strings = load_strings(Some("de"));
let defs = action_definitions();
let confirm_fn = defs[1].confirm_attr.unwrap();
assert_eq!(confirm_fn(strings), "Wirklich abmelden?");
}
}
+150
View File
@@ -0,0 +1,150 @@
// ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown.
// ABOUTME: Wrappers around system commands for the session power menu.
use std::fmt;
use std::io::Read;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
Timeout { action: &'static str },
}
impl fmt::Display for PowerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PowerError::CommandFailed { action, message } => {
write!(f, "{action} failed: {message}")
}
PowerError::Timeout { action } => {
write!(f, "{action} timed out")
}
}
}
}
impl std::error::Error for PowerError {}
/// Run a command with timeout and return a PowerError on failure.
fn run_command(action: &'static str, program: &str, args: &[&str]) -> Result<(), PowerError> {
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
let deadline = Instant::now() + POWER_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(status)) => {
if !status.success() {
let mut stderr_buf = String::new();
if let Some(mut stderr) = child.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_buf);
}
return Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", status, stderr_buf.trim()),
});
}
return Ok(());
}
Ok(None) => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(PowerError::Timeout { action });
}
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
return Err(PowerError::CommandFailed {
action,
message: e.to_string(),
});
}
}
}
}
/// Lock the current session by launching moonlock.
pub fn lock() -> Result<(), PowerError> {
run_command("lock", "/usr/bin/moonlock", &[])
}
/// Quit the Niri compositor (logout).
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "/usr/bin/niri", &["msg", "action", "quit"])
}
/// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "/usr/bin/systemctl", &["hibernate"])
}
/// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "/usr/bin/loginctl", &["reboot"])
}
/// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "/usr/bin/loginctl", &["poweroff"])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn power_error_command_failed_display() {
let err = PowerError::CommandFailed {
action: "lock",
message: "No such file or directory".to_string(),
};
assert_eq!(err.to_string(), "lock failed: No such file or directory");
}
#[test]
fn power_error_timeout_display() {
let err = PowerError::Timeout { action: "shutdown" };
assert_eq!(err.to_string(), "shutdown timed out");
}
#[test]
fn run_command_returns_error_for_missing_binary() {
let result = run_command("test", "nonexistent-binary-xyz", &[]);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PowerError::CommandFailed { action: "test", .. }));
}
#[test]
fn run_command_returns_error_on_nonzero_exit() {
let result = run_command("test", "false", &[]);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, PowerError::CommandFailed { action: "test", .. }));
}
#[test]
fn run_command_succeeds_for_true() {
let result = run_command("test", "true", &[]);
assert!(result.is_ok());
}
#[test]
fn run_command_passes_args() {
// "echo" with args should succeed
let result = run_command("test", "echo", &["hello", "world"]);
assert!(result.is_ok());
}
}
+141
View File
@@ -0,0 +1,141 @@
// ABOUTME: Current user detection and avatar loading for the power menu.
// ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
use nix::unistd::{getuid, User as NixUser};
use std::path::{Path, PathBuf};
const DEFAULT_ACCOUNTSSERVICE_DIR: &str = "/var/lib/AccountsService/icons";
/// Represents the current user for the power menu.
#[derive(Debug, Clone)]
pub struct User {
pub username: String,
pub display_name: String,
pub home: PathBuf,
pub uid: u32,
}
/// Get the currently logged-in user's info from the system.
pub fn get_current_user() -> Option<User> {
let uid = getuid();
let nix_user = NixUser::from_uid(uid).ok()??;
let gecos = nix_user.gecos.to_str().unwrap_or("").to_string();
// GECOS field may contain comma-separated values; first field is the full name
let display_name = if !gecos.is_empty() {
let first = gecos.split(',').next().unwrap_or("");
if first.is_empty() {
nix_user.name.clone()
} else {
first.to_string()
}
} else {
nix_user.name.clone()
};
Some(User {
username: nix_user.name,
display_name,
home: nix_user.dir,
uid: uid.as_raw(),
})
}
/// Find the user's avatar image, checking ~/.face then AccountsService.
pub fn get_avatar_path(home: &Path, username: Option<&str>) -> Option<PathBuf> {
get_avatar_path_with(home, username, Path::new(DEFAULT_ACCOUNTSSERVICE_DIR))
}
/// Find avatar with configurable AccountsService dir (for testing).
pub fn get_avatar_path_with(
home: &Path,
username: Option<&str>,
accountsservice_dir: &Path,
) -> Option<PathBuf> {
// ~/.face takes priority
let face = home.join(".face");
if face.exists() {
return Some(face);
}
// AccountsService icon
if let Some(name) = username {
if accountsservice_dir.exists() {
let icon = accountsservice_dir.join(name);
if icon.exists() {
return Some(icon);
}
}
}
None
}
/// Return the GResource path to the default avatar SVG.
pub fn get_default_avatar_path() -> String {
let prefix = crate::GRESOURCE_PREFIX;
format!("{prefix}/default-avatar.svg")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn get_current_user_returns_some() {
let user = get_current_user();
assert!(user.is_some());
let user = user.unwrap();
assert!(!user.username.is_empty());
assert!(!user.display_name.is_empty());
assert!(user.home.exists());
}
#[test]
fn returns_face_file_if_exists() {
let dir = tempfile::tempdir().unwrap();
let face = dir.path().join(".face");
fs::write(&face, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
assert_eq!(path, Some(face));
}
#[test]
fn returns_accountsservice_icon_if_exists() {
let dir = tempfile::tempdir().unwrap();
let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap();
let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert_eq!(path, Some(icon));
}
#[test]
fn face_file_takes_priority_over_accountsservice() {
let dir = tempfile::tempdir().unwrap();
let face = dir.path().join(".face");
fs::write(&face, "fake image").unwrap();
let icons_dir = dir.path().join("icons");
fs::create_dir(&icons_dir).unwrap();
let icon = icons_dir.join("testuser");
fs::write(&icon, "fake image").unwrap();
let path = get_avatar_path_with(dir.path(), Some("testuser"), &icons_dir);
assert_eq!(path, Some(face));
}
#[test]
fn returns_none_when_no_avatar() {
let dir = tempfile::tempdir().unwrap();
let path = get_avatar_path_with(dir.path(), None, Path::new("/nonexistent"));
assert!(path.is_none());
}
#[test]
fn default_avatar_path_is_gresource() {
let path = get_default_avatar_path();
assert!(path.contains("default-avatar.svg"));
assert!(path.starts_with("/dev/moonarch/moonset"));
}
}
View File
-71
View File
@@ -1,71 +0,0 @@
# ABOUTME: Tests for configuration loading and wallpaper path resolution.
# ABOUTME: Verifies TOML parsing, fallback hierarchy, and default values.
from pathlib import Path
from unittest.mock import patch
import pytest
from moonset.config import Config, load_config, resolve_background_path
class TestLoadConfig:
"""Tests for TOML config loading."""
def test_returns_default_config_when_no_files_exist(self) -> None:
config = load_config(config_paths=[Path("/nonexistent")])
assert config.background_path is None
def test_reads_background_path_from_toml(self, tmp_path: Path) -> None:
conf = tmp_path / "moonset.toml"
conf.write_text('background_path = "/custom/wallpaper.jpg"\n')
config = load_config(config_paths=[conf])
assert config.background_path == "/custom/wallpaper.jpg"
def test_later_paths_override_earlier(self, tmp_path: Path) -> None:
conf1 = tmp_path / "first.toml"
conf1.write_text('background_path = "/first.jpg"\n')
conf2 = tmp_path / "second.toml"
conf2.write_text('background_path = "/second.jpg"\n')
config = load_config(config_paths=[conf1, conf2])
assert config.background_path == "/second.jpg"
def test_skips_missing_config_files(self, tmp_path: Path) -> None:
conf = tmp_path / "exists.toml"
conf.write_text('background_path = "/exists.jpg"\n')
config = load_config(config_paths=[Path("/nonexistent"), conf])
assert config.background_path == "/exists.jpg"
def test_default_config_has_none_background(self) -> None:
config = Config()
assert config.background_path is None
class TestResolveBackgroundPath:
"""Tests for wallpaper path resolution fallback hierarchy."""
def test_uses_config_path_when_file_exists(self, tmp_path: Path) -> None:
wallpaper = tmp_path / "custom.jpg"
wallpaper.touch()
config = Config(background_path=str(wallpaper))
assert resolve_background_path(config) == wallpaper
def test_ignores_config_path_when_file_missing(self, tmp_path: Path) -> None:
config = Config(background_path="/nonexistent/wallpaper.jpg")
# Falls through to system or package fallback
result = resolve_background_path(config)
assert result is not None
def test_uses_moonarch_wallpaper_as_second_fallback(self, tmp_path: Path) -> None:
moonarch_wp = tmp_path / "wallpaper.jpg"
moonarch_wp.touch()
config = Config(background_path=None)
with patch("moonset.config.MOONARCH_WALLPAPER", moonarch_wp):
assert resolve_background_path(config) == moonarch_wp
def test_uses_package_fallback_as_last_resort(self) -> None:
config = Config(background_path=None)
with patch("moonset.config.MOONARCH_WALLPAPER", Path("/nonexistent")):
result = resolve_background_path(config)
# Package fallback should always exist
assert result is not None
-85
View File
@@ -1,85 +0,0 @@
# ABOUTME: Tests for locale detection and string lookup.
# ABOUTME: Verifies DE/EN string tables and locale fallback behavior.
from pathlib import Path
from unittest.mock import patch
from moonset.i18n import Strings, detect_locale, load_strings
class TestDetectLocale:
"""Tests for locale detection from environment and config files."""
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
def test_detects_german_from_env(self) -> None:
assert detect_locale() == "de"
@patch.dict("os.environ", {"LANG": "en_US.UTF-8"})
def test_detects_english_from_env(self) -> None:
assert detect_locale() == "en"
@patch.dict("os.environ", {"LANG": ""})
def test_reads_locale_conf_when_env_empty(self, tmp_path: Path) -> None:
conf = tmp_path / "locale.conf"
conf.write_text("LANG=de_DE.UTF-8\n")
assert detect_locale(locale_conf_path=conf) == "de"
@patch.dict("os.environ", {}, clear=True)
def test_reads_locale_conf_when_env_unset(self, tmp_path: Path) -> None:
conf = tmp_path / "locale.conf"
conf.write_text("LANG=en_GB.UTF-8\n")
assert detect_locale(locale_conf_path=conf) == "en"
@patch.dict("os.environ", {"LANG": "C"})
def test_c_locale_falls_back_to_english(self) -> None:
assert detect_locale() == "en"
@patch.dict("os.environ", {"LANG": "POSIX"})
def test_posix_locale_falls_back_to_english(self) -> None:
assert detect_locale() == "en"
@patch.dict("os.environ", {}, clear=True)
def test_missing_conf_falls_back_to_english(self) -> None:
assert detect_locale(locale_conf_path=Path("/nonexistent")) == "en"
@patch.dict("os.environ", {"LANG": "fr_FR.UTF-8"})
def test_detects_unsupported_locale(self) -> None:
assert detect_locale() == "fr"
class TestLoadStrings:
"""Tests for string table loading."""
def test_loads_german_strings(self) -> None:
strings = load_strings("de")
assert isinstance(strings, Strings)
assert strings.lock_label == "Sperren"
def test_loads_english_strings(self) -> None:
strings = load_strings("en")
assert isinstance(strings, Strings)
assert strings.lock_label == "Lock"
def test_unknown_locale_falls_back_to_english(self) -> None:
strings = load_strings("fr")
assert strings.lock_label == "Lock"
def test_all_string_fields_are_nonempty(self) -> None:
for locale in ("de", "en"):
strings = load_strings(locale)
for field_name in Strings.__dataclass_fields__:
value = getattr(strings, field_name)
assert value, f"{locale}: {field_name} is empty"
def test_confirm_yes_no_present(self) -> None:
strings = load_strings("de")
assert strings.confirm_yes == "Ja"
assert strings.confirm_no == "Abbrechen"
def test_error_messages_present(self) -> None:
strings = load_strings("en")
assert "failed" in strings.lock_failed.lower()
assert "failed" in strings.logout_failed.lower()
assert "failed" in strings.hibernate_failed.lower()
assert "failed" in strings.reboot_failed.lower()
assert "failed" in strings.shutdown_failed.lower()
-74
View File
@@ -1,74 +0,0 @@
# ABOUTME: Integration tests for the moonset power menu.
# ABOUTME: Verifies that all modules work together correctly.
from pathlib import Path
from unittest.mock import patch
from moonset.config import Config, load_config, resolve_background_path
from moonset.i18n import Strings, load_strings
from moonset.panel import ACTION_DEFINITIONS, ActionDef
from moonset.power import POWER_TIMEOUT
class TestModuleIntegration:
"""Tests that verify modules work together."""
def test_action_defs_reference_valid_power_functions(self) -> None:
"""Each ActionDef references a function from power.py."""
from moonset import power
power_functions = {
power.lock, power.logout, power.hibernate,
power.reboot, power.shutdown,
}
for action_def in ACTION_DEFINITIONS:
assert action_def.action_fn in power_functions, (
f"{action_def.name} references unknown power function"
)
def test_action_defs_match_i18n_fields_de(self) -> None:
"""All label/error/confirm attrs in ActionDefs exist in DE strings."""
strings = load_strings("de")
for action_def in ACTION_DEFINITIONS:
assert hasattr(strings, action_def.label_attr)
assert hasattr(strings, action_def.error_attr)
if action_def.confirm_attr:
assert hasattr(strings, action_def.confirm_attr)
def test_action_defs_match_i18n_fields_en(self) -> None:
"""All label/error/confirm attrs in ActionDefs exist in EN strings."""
strings = load_strings("en")
for action_def in ACTION_DEFINITIONS:
assert hasattr(strings, action_def.label_attr)
assert hasattr(strings, action_def.error_attr)
if action_def.confirm_attr:
assert hasattr(strings, action_def.confirm_attr)
def test_config_defaults_produce_valid_background_path(self) -> None:
"""Default config resolves to an existing wallpaper file."""
config = Config()
path = resolve_background_path(config)
assert path.suffix in (".jpg", ".png", ".webp")
def test_full_config_to_strings_flow(self, tmp_path: Path) -> None:
"""Config loading and string loading work independently."""
conf = tmp_path / "moonset.toml"
conf.write_text('background_path = "/custom/path.jpg"\n')
config = load_config(config_paths=[conf])
assert config.background_path == "/custom/path.jpg"
strings = load_strings("de")
assert strings.lock_label == "Sperren"
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
def test_german_locale_produces_german_labels(self) -> None:
"""Full flow: German locale → German button labels."""
strings = load_strings()
for action_def in ACTION_DEFINITIONS:
label = action_def.get_label(strings)
assert label
# German labels should not be the English ones
en_strings = load_strings("en")
en_label = action_def.get_label(en_strings)
assert label != en_label, (
f"{action_def.name}: DE and EN labels are identical"
)
-68
View File
@@ -1,68 +0,0 @@
# ABOUTME: Tests for the power menu panel UI module.
# ABOUTME: Verifies action button creation, confirmation flow, and dismiss behavior.
from unittest.mock import MagicMock, patch, PropertyMock
import subprocess
import pytest
from moonset.i18n import load_strings
from moonset.panel import (
ACTION_DEFINITIONS,
ActionDef,
)
class TestActionDefinitions:
"""Tests for action definition structure."""
def test_has_five_actions(self) -> None:
assert len(ACTION_DEFINITIONS) == 5
def test_action_order_by_destructiveness(self) -> None:
names = [a.name for a in ACTION_DEFINITIONS]
assert names == ["lock", "logout", "hibernate", "reboot", "shutdown"]
def test_lock_has_no_confirmation(self) -> None:
lock_def = ACTION_DEFINITIONS[0]
assert lock_def.name == "lock"
assert lock_def.needs_confirm is False
def test_destructive_actions_need_confirmation(self) -> None:
for action_def in ACTION_DEFINITIONS[1:]:
assert action_def.needs_confirm is True, (
f"{action_def.name} should need confirmation"
)
def test_all_actions_have_icon_names(self) -> None:
for action_def in ACTION_DEFINITIONS:
assert action_def.icon_name, f"{action_def.name} missing icon_name"
assert action_def.icon_name.endswith("-symbolic")
def test_all_actions_have_callable_functions(self) -> None:
for action_def in ACTION_DEFINITIONS:
assert callable(action_def.action_fn)
def test_action_labels_from_strings(self) -> None:
strings = load_strings("en")
for action_def in ACTION_DEFINITIONS:
label = action_def.get_label(strings)
assert label, f"{action_def.name} has empty label"
def test_action_error_messages_from_strings(self) -> None:
strings = load_strings("en")
for action_def in ACTION_DEFINITIONS:
error_msg = action_def.get_error_message(strings)
assert error_msg, f"{action_def.name} has empty error message"
def test_confirmable_actions_have_confirm_prompts(self) -> None:
strings = load_strings("en")
for action_def in ACTION_DEFINITIONS:
if action_def.needs_confirm:
prompt = action_def.get_confirm_prompt(strings)
assert prompt, f"{action_def.name} has empty confirm prompt"
def test_lock_confirm_prompt_is_none(self) -> None:
strings = load_strings("en")
lock_def = ACTION_DEFINITIONS[0]
assert lock_def.get_confirm_prompt(strings) is None
-139
View File
@@ -1,139 +0,0 @@
# ABOUTME: Tests for power actions — lock, logout, hibernate, reboot, shutdown.
# ABOUTME: Uses mocking to avoid actually calling system commands.
import subprocess
from unittest.mock import patch
import pytest
from moonset.power import lock, logout, hibernate, reboot, shutdown, POWER_TIMEOUT
class TestLock:
"""Tests for the lock power action."""
@patch("moonset.power.subprocess.run")
def test_calls_moonlock(self, mock_run) -> None:
lock()
mock_run.assert_called_once_with(
["moonlock"], check=True, timeout=POWER_TIMEOUT
)
@patch("moonset.power.subprocess.run")
def test_raises_on_failure(self, mock_run) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
with pytest.raises(subprocess.CalledProcessError):
lock()
@patch("moonset.power.subprocess.run")
def test_raises_on_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
with pytest.raises(subprocess.TimeoutExpired):
lock()
class TestLogout:
"""Tests for the logout power action."""
@patch("moonset.power.subprocess.run")
def test_calls_niri_quit(self, mock_run) -> None:
logout()
mock_run.assert_called_once_with(
["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT
)
@patch("moonset.power.subprocess.run")
def test_raises_on_failure(self, mock_run) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, "niri")
with pytest.raises(subprocess.CalledProcessError):
logout()
@patch("moonset.power.subprocess.run")
def test_raises_on_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("niri", POWER_TIMEOUT)
with pytest.raises(subprocess.TimeoutExpired):
logout()
class TestHibernate:
"""Tests for the hibernate power action."""
@patch("moonset.power.subprocess.run")
def test_calls_systemctl_hibernate(self, mock_run) -> None:
hibernate()
mock_run.assert_called_once_with(
["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT
)
@patch("moonset.power.subprocess.run")
def test_raises_on_failure(self, mock_run) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, "systemctl")
with pytest.raises(subprocess.CalledProcessError):
hibernate()
@patch("moonset.power.subprocess.run")
def test_raises_on_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("systemctl", POWER_TIMEOUT)
with pytest.raises(subprocess.TimeoutExpired):
hibernate()
class TestReboot:
"""Tests for the reboot power action."""
@patch("moonset.power.subprocess.run")
def test_calls_loginctl_reboot(self, mock_run) -> None:
reboot()
mock_run.assert_called_once_with(
["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT
)
@patch("moonset.power.subprocess.run")
def test_raises_on_failure(self, mock_run) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
with pytest.raises(subprocess.CalledProcessError):
reboot()
@patch("moonset.power.subprocess.run")
def test_raises_on_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
with pytest.raises(subprocess.TimeoutExpired):
reboot()
class TestShutdown:
"""Tests for the shutdown power action."""
@patch("moonset.power.subprocess.run")
def test_calls_loginctl_poweroff(self, mock_run) -> None:
shutdown()
mock_run.assert_called_once_with(
["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT
)
@patch("moonset.power.subprocess.run")
def test_raises_on_failure(self, mock_run) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl")
with pytest.raises(subprocess.CalledProcessError):
shutdown()
@patch("moonset.power.subprocess.run")
def test_raises_on_timeout(self, mock_run) -> None:
mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT)
with pytest.raises(subprocess.TimeoutExpired):
shutdown()
-95
View File
@@ -1,95 +0,0 @@
# ABOUTME: Tests for current user detection and avatar loading.
# ABOUTME: Verifies user info retrieval from the system.
from pathlib import Path
from unittest.mock import patch
from moonset.users import get_current_user, get_avatar_path, get_default_avatar_path, User
class TestGetCurrentUser:
"""Tests for current user detection."""
@patch("moonset.users.os.getuid", return_value=1000)
@patch("moonset.users.pwd.getpwuid")
def test_returns_user_with_correct_username(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = "Test User"
mock_pwd.return_value.pw_dir = "/home/testuser"
mock_pwd.return_value.pw_uid = 1000
user = get_current_user()
assert user.username == "testuser"
assert user.display_name == "Test User"
assert user.home == Path("/home/testuser")
mock_pwd.assert_called_once_with(1000)
@patch("moonset.users.os.getuid", return_value=1000)
@patch("moonset.users.pwd.getpwuid")
def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = ""
mock_pwd.return_value.pw_dir = "/home/testuser"
mock_pwd.return_value.pw_uid = 1000
user = get_current_user()
assert user.display_name == "testuser"
@patch("moonset.users.os.getuid", return_value=1000)
@patch("moonset.users.pwd.getpwuid")
def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_uid):
mock_pwd.return_value.pw_name = "testuser"
mock_pwd.return_value.pw_gecos = "Test User,,,Room 42"
mock_pwd.return_value.pw_dir = "/home/testuser"
mock_pwd.return_value.pw_uid = 1000
user = get_current_user()
assert user.display_name == "Test User"
class TestGetAvatarPath:
"""Tests for avatar path resolution."""
def test_returns_face_file_if_exists(self, tmp_path: Path):
face = tmp_path / ".face"
face.write_text("fake image")
path = get_avatar_path(tmp_path)
assert path == face
def test_returns_accountsservice_icon_if_exists(self, tmp_path: Path):
username = "testuser"
icons_dir = tmp_path / "icons"
icons_dir.mkdir()
icon = icons_dir / username
icon.write_text("fake image")
path = get_avatar_path(
tmp_path, username=username, accountsservice_dir=icons_dir
)
assert path == icon
def test_face_file_takes_priority_over_accountsservice(self, tmp_path: Path):
face = tmp_path / ".face"
face.write_text("fake image")
icons_dir = tmp_path / "icons"
icons_dir.mkdir()
icon = icons_dir / "testuser"
icon.write_text("fake image")
path = get_avatar_path(
tmp_path, username="testuser", accountsservice_dir=icons_dir
)
assert path == face
def test_returns_none_when_no_avatar(self, tmp_path: Path):
path = get_avatar_path(tmp_path)
assert path is None
class TestGetDefaultAvatarPath:
"""Tests for default avatar fallback."""
def test_default_avatar_exists(self):
"""The package default avatar must always be present."""
path = get_default_avatar_path()
assert path.is_file()
def test_default_avatar_is_svg(self):
"""The default avatar should be an SVG file."""
path = get_default_avatar_path()
assert path.suffix == ".svg"
Generated
-45
View File
@@ -1,45 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "moonset"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "pygobject" },
]
[package.metadata]
requires-dist = [{ name = "pygobject", specifier = ">=3.46" }]
[[package]]
name = "pycairo"
version = "1.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" },
{ url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" },
{ url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" },
{ url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" },
{ url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" },
{ url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" },
{ url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" },
{ url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" },
{ url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" },
{ url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" },
{ url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" },
{ url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" },
{ url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" },
]
[[package]]
name = "pygobject"
version = "3.56.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycairo" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }