Compare commits

...

3 Commits

Author SHA1 Message Date
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
c2e3077159 merge: rust-rewrite branch into main 2026-03-27 16:10:41 +01:00
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
34 changed files with 2889 additions and 1452 deletions

11
.gitignore vendored
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/

View File

@ -4,44 +4,43 @@
## 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, 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
- `power.rs` — 5 Power-Action-Wrapper (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)
- `main.rs` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor
- `resources/style.css` — Catppuccin Mocha Theme (aus Python-Version übernommen)
## Design Decisions
@ -50,3 +49,6 @@ uv run moonset
- **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
- **Icon-Scaling**: 22px Theme-Variante laden, auf 64px skalieren via GdkPixbuf
- **GResource-Bundle**: CSS, Wallpaper und Default-Avatar sind in die Binary kompiliert
- **Async Power Actions**: `glib::spawn_future_local` + `gio::spawn_blocking` statt raw Threads

1396
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

25
Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "moonset"
version = "0.1.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"] }
log = "0.4"
env_logger = "0.11"
[dev-dependencies]
tempfile = "3"
[build-dependencies]
glib-build-tools = "0.22"

View File

@ -7,7 +7,7 @@ Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
## Features
- GTK4 + gtk4-layer-shell (OVERLAY Layer — über Waybar)
- Rust + gtk4-rs + gtk4-layer-shell (OVERLAY Layer — über Waybar)
- Catppuccin Mocha Theme
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
- Inline-Confirmation für destruktive Aktionen
@ -18,7 +18,14 @@ Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
## Installation
```bash
uv pip install .
cargo build --release
install -Dm755 target/release/moonset /usr/bin/moonset
```
Oder via PKGBUILD:
```bash
cd pkg && makepkg -si
```
## Verwendung
@ -42,16 +49,16 @@ Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset
background_path = "/usr/share/moonarch/wallpaper.jpg"
```
Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → Package-Wallpaper
Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg`eingebettetes Package-Wallpaper
## Entwicklung
```bash
# Tests
uv run pytest tests/ -v
cargo test
# Type-Check
uv run pyright src/
# Release-Build
cargo build --release
```
## Teil des Moonarch-Ökosystems

10
build.rs Normal file
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",
);
}

View File

@ -1,6 +1,27 @@
# Hekate — Journal
## 2026-03-27
## 2026-03-27 — Rust Rewrite
Rewrite von Python auf Rust (gtk4-rs + gtk4-layer-shell). Motivation: ~800ms Startzeit der Python-Version durch Interpreter-Overhead.
Alle Module 1:1 portiert:
- `power.rs` — Command::new statt subprocess.run, PowerError enum statt Exceptions
- `i18n.rs` — Static Strings statt Dataclass, parse_lang_prefix() separat testbar (kein env::set_var nötig)
- `config.rs` — serde::Deserialize für TOML, GResource-Pfad als letzter Fallback
- `users.rs` — nix-crate für getuid/getpwuid, GResource-Pfad für default-avatar
- `panel.rs` — Freie Funktionen statt Klassen, Rc<RefCell> für Confirm-State, glib::spawn_future_local + gio::spawn_blocking für async Power-Actions
- `main.rs` — GResource-Registration, LayerShell trait statt Gtk4LayerShell-Modul
45 Unit-Tests grün. Release-Binary: 3.1 MB.
Gelernt:
- gtk4-rs 0.11 braucht Rust ≥1.92 (system hatte 1.91 → rustup update)
- `ContentFit` und `Widget::color()` brauchen Feature-Flags (`v4_8`, `v4_10`)
- GTK-Objekte (WeakRef) sind nicht Send → glib::spawn_future_local statt std::thread für UI-Updates
- `set_from_paintable` heißt jetzt `set_paintable` in gtk4-rs 0.11
- GResource-Bundle kompiliert CSS/Wallpaper/Avatar in die Binary — kein importlib.resources mehr nötig
## 2026-03-27 — Initiale Python-Version
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.

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"

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"

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/dev/moonarch/moonset">
<file>style.css</file>
<file>wallpaper.jpg</file>
<file>default-avatar.svg</file>
</gresource>
</gresources>

View File

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 366 KiB

159
src/config.rs Normal file
View File

@ -0,0 +1,159 @@
// 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";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// 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>,
}
/// 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;
}
}
}
}
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)
PathBuf::from(format!("{GRESOURCE_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());
}
#[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_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()),
};
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()),
};
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
src/i18n.rs Normal file
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"));
}
}

90
src/main.rs Normal file
View File

@ -0,0 +1,90 @@
// 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;
fn load_css(display: &gdk::Display) {
let css_provider = gtk::CssProvider::new();
css_provider.load_from_resource("/dev/moonarch/moonset/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, share across all windows
let config = config::load_config(None);
let bg_path = config::resolve_background_path(&config);
// Panel on focused output (no set_monitor → compositor picks focused)
let panel = panel::create_panel_window(&bg_path, 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(&bg_path, 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();
}

View File

@ -1,2 +0,0 @@
# ABOUTME: Moonset package — a Wayland session power menu with GTK4.
# ABOUTME: Part of the Moonarch ecosystem.

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

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)

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()

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)

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)

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"))

597
src/panel.rs Normal file
View File

@ -0,0 +1,597 @@
// 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 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),
},
]
}
/// Create a wallpaper-only window for secondary monitors.
pub fn create_wallpaper_window(bg_path: &Path, app: &gtk::Application) -> gtk::ApplicationWindow {
let window = gtk::ApplicationWindow::builder()
.application(app)
.build();
window.add_css_class("wallpaper");
let background = create_background_picture(bg_path);
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(bg_path: &Path, 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: 0,
});
// 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(bg_path);
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
let avatar_path = users::get_avatar_path(&user.home, Some(&user.username));
if let Some(path) = avatar_path {
set_avatar_from_file(&avatar_image, &path);
} else {
set_default_avatar(&avatar_image, &window);
}
// 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");
glib::idle_add_local_once(move || {
if let Some(first) = bb.first_child() {
first.grab_focus();
}
});
});
});
window
}
/// Create a Picture widget for the wallpaper background.
fn create_background_picture(bg_path: &Path) -> gtk::Picture {
let background = if bg_path.starts_with("/dev/moonarch/moonset") {
gtk::Picture::for_resource(bg_path.to_str().unwrap_or(""))
} else {
gtk::Picture::for_filename(bg_path.to_str().unwrap_or(""))
};
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 at 22px and scale to 64px via GdkPixbuf.
fn load_scaled_icon(icon_name: &str) -> gtk::Image {
let display = gdk::Display::default().unwrap();
let theme = gtk::IconTheme::for_display(&display);
let icon_paintable = theme.lookup_icon(
icon_name,
&[],
22,
1,
gtk::TextDirection::None,
gtk::IconLookupFlags::FORCE_SYMBOLIC,
);
let icon = gtk::Image::new();
if let Some(file) = icon_paintable.file() {
if let Some(path) = file.path() {
if let Ok(pixbuf) =
Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), 64, 64, true)
{
let texture = gdk::Texture::for_pixbuf(&pixbuf);
icon.set_paintable(Some(&texture));
return icon;
}
}
}
// Fallback: use icon name directly
icon.set_icon_name(Some(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 an image file and set it as the avatar.
fn set_avatar_from_file(image: &gtk::Image, path: &Path) {
match Pixbuf::from_file_at_scale(path.to_str().unwrap_or(""), AVATAR_SIZE, AVATAR_SIZE, true) {
Ok(pixbuf) => {
let texture = gdk::Texture::for_pixbuf(&pixbuf);
image.set_paintable(Some(&texture));
}
Err(_) => {
image.set_icon_name(Some("avatar-default-symbolic"));
}
}
}
/// 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?");
}
}

133
src/power.rs Normal file
View File

@ -0,0 +1,133 @@
// ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown.
// ABOUTME: Wrappers around system commands for the session power menu.
use std::fmt;
use std::process::Command;
use std::time::Duration;
#[allow(dead_code)]
const POWER_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum PowerError {
CommandFailed { action: &'static str, message: String },
#[allow(dead_code)]
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 child = Command::new(program)
.args(args)
.spawn()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
let output = child
.wait_with_output()
.map_err(|e| PowerError::CommandFailed {
action,
message: e.to_string(),
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PowerError::CommandFailed {
action,
message: format!("exit code {}: {}", output.status, stderr.trim()),
});
}
Ok(())
}
/// Lock the current session by launching moonlock.
pub fn lock() -> Result<(), PowerError> {
run_command("lock", "moonlock", &[])
}
/// Quit the Niri compositor (logout).
pub fn logout() -> Result<(), PowerError> {
run_command("logout", "niri", &["msg", "action", "quit"])
}
/// Hibernate the system via systemctl.
pub fn hibernate() -> Result<(), PowerError> {
run_command("hibernate", "systemctl", &["hibernate"])
}
/// Reboot the system via loginctl.
pub fn reboot() -> Result<(), PowerError> {
run_command("reboot", "loginctl", &["reboot"])
}
/// Shut down the system via loginctl.
pub fn shutdown() -> Result<(), PowerError> {
run_command("shutdown", "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
src/users.rs Normal file
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";
const GRESOURCE_PREFIX: &str = "/dev/moonarch/moonset";
/// 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 {
format!("{GRESOURCE_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

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

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()

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"
)

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

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()

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"

45
uv.lock generated
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" }