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.
This commit is contained in:
parent
9deaaacc33
commit
e66ef76b4d
5
.gitignore
vendored
5
.gitignore
vendored
@ -14,3 +14,8 @@ pkg/src/
|
||||
pkg/pkg/
|
||||
pkg/*.pkg.tar*
|
||||
pkg/moonset/
|
||||
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
||||
39
CLAUDE.md
39
CLAUDE.md
@ -4,44 +4,46 @@
|
||||
|
||||
## 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
|
||||
- Python-Quellen in `src/moonset/` als Referenz erhalten
|
||||
|
||||
## 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
|
||||
- `src/moonset/` — Python-Referenzimplementierung (v0.2.0)
|
||||
- `tests/` — Python-Tests (Referenz)
|
||||
|
||||
## 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 +52,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
1396
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal 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"
|
||||
19
README.md
19
README.md
@ -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
10
build.rs
Normal 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",
|
||||
);
|
||||
}
|
||||
23
journal.md
23
journal.md
@ -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.
|
||||
|
||||
|
||||
13
pkg/PKGBUILD
13
pkg/PKGBUILD
@ -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"
|
||||
|
||||
1
resources/default-avatar.svg
Normal file
1
resources/default-avatar.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#PLACEHOLDER" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
8
resources/resources.gresource.xml
Normal file
8
resources/resources.gresource.xml
Normal 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>
|
||||
105
resources/style.css
Normal file
105
resources/style.css
Normal file
@ -0,0 +1,105 @@
|
||||
/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */
|
||||
/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */
|
||||
|
||||
/* Main panel window background */
|
||||
window.panel {
|
||||
background-color: @theme_bg_color;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Wallpaper-only window for secondary monitors */
|
||||
window.wallpaper {
|
||||
background-color: @theme_bg_color;
|
||||
}
|
||||
|
||||
/* Round avatar image */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
min-width: 128px;
|
||||
min-height: 128px;
|
||||
background-color: @theme_selected_bg_color;
|
||||
border: 3px solid alpha(white, 0.3);
|
||||
}
|
||||
|
||||
/* Username label */
|
||||
.username-label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Action button — square card */
|
||||
.action-button {
|
||||
min-width: 120px;
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: alpha(@theme_base_color, 0.55);
|
||||
color: @theme_fg_color;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: alpha(@theme_base_color, 0.7);
|
||||
}
|
||||
|
||||
/* Action icon inside button — request 48px from theme, scale up via CSS */
|
||||
.action-icon {
|
||||
color: @theme_fg_color;
|
||||
-gtk-icon-size: 64px;
|
||||
}
|
||||
|
||||
/* Action label below icon */
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
color: @theme_unfocused_fg_color;
|
||||
}
|
||||
|
||||
/* Confirmation box below action buttons */
|
||||
.confirm-box {
|
||||
padding: 16px 24px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Confirmation prompt text */
|
||||
.confirm-label {
|
||||
font-size: 16px;
|
||||
color: @theme_fg_color;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Confirm "Yes" button */
|
||||
.confirm-yes {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: @error_color;
|
||||
color: @theme_bg_color;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.confirm-yes:hover {
|
||||
background-color: lighter(@error_color);
|
||||
}
|
||||
|
||||
/* Confirm "No/Cancel" button */
|
||||
.confirm-no {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: @theme_unfocused_bg_color;
|
||||
color: @theme_fg_color;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.confirm-no:hover {
|
||||
background-color: @theme_selected_bg_color;
|
||||
}
|
||||
|
||||
/* Error message label */
|
||||
.error-label {
|
||||
color: @error_color;
|
||||
font-size: 14px;
|
||||
}
|
||||
BIN
resources/wallpaper.jpg
Normal file
BIN
resources/wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
159
src/config.rs
Normal file
159
src/config.rs
Normal 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
271
src/i18n.rs
Normal 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
90
src/main.rs
Normal 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: >k::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: >k::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();
|
||||
}
|
||||
597
src/panel.rs
Normal file
597
src/panel.rs
Normal 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: >k::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: >k::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: >k::Application,
|
||||
confirm_area: >k::Box,
|
||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
||||
error_label: >k::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: >k::Application,
|
||||
confirm_area: >k::Box,
|
||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
||||
error_label: >k::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: >k::Application,
|
||||
confirm_area: >k::Box,
|
||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
||||
error_label: >k::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: >k::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: >k::Application,
|
||||
confirm_area: >k::Box,
|
||||
confirm_box: &Rc<RefCell<Option<gtk::Box>>>,
|
||||
error_label: >k::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: >k::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: >k::Image, window: >k::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
133
src/power.rs
Normal 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
141
src/users.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user