feat: initial moonset implementation — Wayland session power menu v0.1.0
5 Power-Aktionen (lock, logout, hibernate, reboot, shutdown), GTK4 + Layer Shell UI mit Catppuccin Mocha Theme, Multi-Monitor-Support, Inline-Confirmation, DE/EN i18n, TOML-Config mit Wallpaper-Fallback. 54 Tests grün.
This commit is contained in:
commit
4cad984263
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.pyright/
|
||||
*.egg
|
||||
52
CLAUDE.md
Normal file
52
CLAUDE.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Moonset
|
||||
|
||||
**Name**: Hekate (Göttin der Wegkreuzungen — passend zum Power-Menu, das den Weg der Session bestimmt)
|
||||
|
||||
## Projekt
|
||||
|
||||
Moonset ist ein Wayland Session Power Menu, gebaut mit Python + GTK4 + 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
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
- `src/moonset/` — Quellcode
|
||||
- `src/moonset/data/` — Package-Assets (Fallback-Wallpaper)
|
||||
- `tests/` — pytest Tests
|
||||
- `config/` — Beispiel-Konfigurationsdateien
|
||||
|
||||
## Kommandos
|
||||
|
||||
```bash
|
||||
# Tests ausführen
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Typ-Checks
|
||||
uv run pyright src/
|
||||
|
||||
# Power-Menu starten (in Niri-Session)
|
||||
uv run 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
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **OVERLAY statt TOP Layer**: Waybar liegt auf TOP, moonset muss darüber
|
||||
- **Niri-spezifischer Logout** (`niri msg action quit`): Moonarch setzt fest auf Niri
|
||||
- **Einmal-Start per Keybind**: Kein Daemon, GTK `application_id` verhindert Doppelstart
|
||||
- **System-Icons**: Adwaita/Catppuccin liefern alle benötigten symbolischen Icons
|
||||
- **Lock ohne Confirmation**: Lock ist sofort reversibel, braucht kein Confirm
|
||||
62
README.md
Normal file
62
README.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Moonset
|
||||
|
||||
Wayland Session Power Menu für das Moonarch-Ökosystem.
|
||||
|
||||
Per Keybind aufrufbares Fullscreen-Overlay mit 5 Aktionen:
|
||||
**Lock** · **Logout** · **Hibernate** · **Reboot** · **Shutdown**
|
||||
|
||||
## Features
|
||||
|
||||
- GTK4 + gtk4-layer-shell (OVERLAY Layer — über Waybar)
|
||||
- Catppuccin Mocha Theme
|
||||
- Multi-Monitor-Support (Wallpaper auf Sekundärmonitoren)
|
||||
- Inline-Confirmation für destruktive Aktionen
|
||||
- Escape oder Hintergrund-Klick zum Schließen
|
||||
- DE/EN Lokalisierung
|
||||
- Konfigurierbare Wallpaper (TOML)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
uv pip install .
|
||||
```
|
||||
|
||||
## Verwendung
|
||||
|
||||
```bash
|
||||
# Direkt starten
|
||||
moonset
|
||||
|
||||
# Per Niri-Keybind (in ~/.config/niri/config.kdl)
|
||||
# binds {
|
||||
# Mod+Escape { spawn "moonset"; }
|
||||
# }
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Konfigurationsdatei: `~/.config/moonset/moonset.toml` oder `/etc/moonset/moonset.toml`
|
||||
|
||||
```toml
|
||||
# Pfad zum Hintergrundbild (optional)
|
||||
background_path = "/usr/share/moonarch/wallpaper.jpg"
|
||||
```
|
||||
|
||||
Wallpaper-Fallback: Konfiguration → `/usr/share/moonarch/wallpaper.jpg` → Package-Wallpaper
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
# Tests
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Type-Check
|
||||
uv run pyright src/
|
||||
```
|
||||
|
||||
## Teil des Moonarch-Ökosystems
|
||||
|
||||
- **moonarch** — Reproduzierbares Arch-Linux-Setup
|
||||
- **moongreet** — greetd Greeter für Wayland
|
||||
- **moonlock** — Wayland Lockscreen
|
||||
- **moonset** — Session Power Menu
|
||||
6
config/moonset.toml
Normal file
6
config/moonset.toml
Normal file
@ -0,0 +1,6 @@
|
||||
# Moonset — Wayland Session Power Menu
|
||||
# Konfigurationsdatei: ~/.config/moonset/moonset.toml oder /etc/moonset/moonset.toml
|
||||
|
||||
# Pfad zum Hintergrundbild (optional)
|
||||
# Fallback-Reihenfolge: config → /usr/share/moonarch/wallpaper.jpg → Package-Wallpaper
|
||||
# background_path = "/usr/share/moonarch/wallpaper.jpg"
|
||||
30
pyproject.toml
Normal file
30
pyproject.toml
Normal file
@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "moonset"
|
||||
version = "0.1.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"
|
||||
2
src/moonset/__init__.py
Normal file
2
src/moonset/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# ABOUTME: Moonset package — a Wayland session power menu with GTK4.
|
||||
# ABOUTME: Part of the Moonarch ecosystem.
|
||||
60
src/moonset/config.py
Normal file
60
src/moonset/config.py
Normal file
@ -0,0 +1,60 @@
|
||||
# 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
|
||||
BIN
src/moonset/data/wallpaper.jpg
Normal file
BIN
src/moonset/data/wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
108
src/moonset/i18n.py
Normal file
108
src/moonset/i18n.py
Normal file
@ -0,0 +1,108 @@
|
||||
# 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)
|
||||
134
src/moonset/main.py
Normal file
134
src/moonset/main.py
Normal file
@ -0,0 +1,134 @@
|
||||
# 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 sys
|
||||
from importlib.resources import files
|
||||
|
||||
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")
|
||||
self._secondary_windows: list[WallpaperWindow] = []
|
||||
|
||||
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)
|
||||
|
||||
monitors = display.get_monitors()
|
||||
primary_monitor = None
|
||||
|
||||
# Find primary monitor — fall back to first available
|
||||
for i in range(monitors.get_n_items()):
|
||||
monitor = monitors.get_item(i)
|
||||
if hasattr(monitor, "is_primary") and monitor.is_primary():
|
||||
primary_monitor = monitor
|
||||
break
|
||||
if primary_monitor is None and monitors.get_n_items() > 0:
|
||||
primary_monitor = monitors.get_item(0)
|
||||
|
||||
# Main power menu window on primary monitor
|
||||
panel = PanelWindow(bg_path=bg_path, application=self)
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(panel, keyboard=True)
|
||||
if primary_monitor is not None:
|
||||
Gtk4LayerShell.set_monitor(panel, primary_monitor)
|
||||
panel.present()
|
||||
|
||||
# Wallpaper-only windows on secondary monitors
|
||||
for i in range(monitors.get_n_items()):
|
||||
monitor = monitors.get_item(i)
|
||||
if monitor == primary_monitor:
|
||||
continue
|
||||
wallpaper_win = WallpaperWindow(bg_path=bg_path, application=self)
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(wallpaper_win, keyboard=False)
|
||||
Gtk4LayerShell.set_monitor(wallpaper_win, monitor)
|
||||
wallpaper_win.present()
|
||||
self._secondary_windows.append(wallpaper_win)
|
||||
|
||||
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
|
||||
) -> None:
|
||||
"""Configure gtk4-layer-shell for fullscreen OVERLAY display."""
|
||||
Gtk4LayerShell.init_for_window(window)
|
||||
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.OVERLAY)
|
||||
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()
|
||||
292
src/moonset/panel.py
Normal file
292
src/moonset/panel.py
Normal file
@ -0,0 +1,292 @@
|
||||
# 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, GLib
|
||||
|
||||
from moonset.i18n import Strings, load_strings
|
||||
from moonset import power
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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._confirm_box: Gtk.Box | None = None
|
||||
|
||||
self._build_ui(bg_path)
|
||||
self._setup_keyboard()
|
||||
|
||||
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)
|
||||
|
||||
# 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=8
|
||||
)
|
||||
button_content.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
icon = Gtk.Image.new_from_icon_name(action_def.icon_name)
|
||||
icon.add_css_class("action-icon")
|
||||
icon.set_pixel_size(64)
|
||||
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_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)
|
||||
|
||||
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 _show_error(self, message: str) -> None:
|
||||
"""Display an error message."""
|
||||
self._error_label.set_text(message)
|
||||
self._error_label.set_visible(True)
|
||||
34
src/moonset/power.py
Normal file
34
src/moonset/power.py
Normal file
@ -0,0 +1,34 @@
|
||||
# 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 via loginctl."""
|
||||
subprocess.run(["loginctl", "lock-session"], 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)
|
||||
87
src/moonset/style.css
Normal file
87
src/moonset/style.css
Normal file
@ -0,0 +1,87 @@
|
||||
/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */
|
||||
/* ABOUTME: Catppuccin Mocha theme with action buttons and confirmation styling. */
|
||||
|
||||
/* Main panel window background */
|
||||
window.panel {
|
||||
background-color: #1a1a2e;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Wallpaper-only window for secondary monitors */
|
||||
window.wallpaper {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Action button — 120x120px card */
|
||||
.action-button {
|
||||
min-width: 120px;
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background-color: alpha(white, 0.08);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: alpha(white, 0.20);
|
||||
}
|
||||
|
||||
/* Action icon inside button */
|
||||
.action-icon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Action label below icon */
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Confirmation box below action buttons */
|
||||
.confirm-box {
|
||||
padding: 16px 24px;
|
||||
border-radius: 12px;
|
||||
background-color: alpha(white, 0.08);
|
||||
}
|
||||
|
||||
/* Confirmation prompt text */
|
||||
.confirm-label {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Confirm "Yes" button */
|
||||
.confirm-yes {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.confirm-yes:hover {
|
||||
background-color: #ff8787;
|
||||
}
|
||||
|
||||
/* Confirm "No/Cancel" button */
|
||||
.confirm-no {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: alpha(white, 0.12);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.confirm-no:hover {
|
||||
background-color: alpha(white, 0.25);
|
||||
}
|
||||
|
||||
/* Error message label */
|
||||
.error-label {
|
||||
color: #ff6b6b;
|
||||
font-size: 14px;
|
||||
}
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
71
tests/test_config.py
Normal file
71
tests/test_config.py
Normal file
@ -0,0 +1,71 @@
|
||||
# ABOUTME: Tests for configuration loading and wallpaper path resolution.
|
||||
# ABOUTME: Verifies TOML parsing, fallback hierarchy, and default values.
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from moonset.config import Config, load_config, resolve_background_path
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Tests for TOML config loading."""
|
||||
|
||||
def test_returns_default_config_when_no_files_exist(self) -> None:
|
||||
config = load_config(config_paths=[Path("/nonexistent")])
|
||||
assert config.background_path is None
|
||||
|
||||
def test_reads_background_path_from_toml(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "moonset.toml"
|
||||
conf.write_text('background_path = "/custom/wallpaper.jpg"\n')
|
||||
config = load_config(config_paths=[conf])
|
||||
assert config.background_path == "/custom/wallpaper.jpg"
|
||||
|
||||
def test_later_paths_override_earlier(self, tmp_path: Path) -> None:
|
||||
conf1 = tmp_path / "first.toml"
|
||||
conf1.write_text('background_path = "/first.jpg"\n')
|
||||
conf2 = tmp_path / "second.toml"
|
||||
conf2.write_text('background_path = "/second.jpg"\n')
|
||||
config = load_config(config_paths=[conf1, conf2])
|
||||
assert config.background_path == "/second.jpg"
|
||||
|
||||
def test_skips_missing_config_files(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "exists.toml"
|
||||
conf.write_text('background_path = "/exists.jpg"\n')
|
||||
config = load_config(config_paths=[Path("/nonexistent"), conf])
|
||||
assert config.background_path == "/exists.jpg"
|
||||
|
||||
def test_default_config_has_none_background(self) -> None:
|
||||
config = Config()
|
||||
assert config.background_path is None
|
||||
|
||||
|
||||
class TestResolveBackgroundPath:
|
||||
"""Tests for wallpaper path resolution fallback hierarchy."""
|
||||
|
||||
def test_uses_config_path_when_file_exists(self, tmp_path: Path) -> None:
|
||||
wallpaper = tmp_path / "custom.jpg"
|
||||
wallpaper.touch()
|
||||
config = Config(background_path=str(wallpaper))
|
||||
assert resolve_background_path(config) == wallpaper
|
||||
|
||||
def test_ignores_config_path_when_file_missing(self, tmp_path: Path) -> None:
|
||||
config = Config(background_path="/nonexistent/wallpaper.jpg")
|
||||
# Falls through to system or package fallback
|
||||
result = resolve_background_path(config)
|
||||
assert result is not None
|
||||
|
||||
def test_uses_moonarch_wallpaper_as_second_fallback(self, tmp_path: Path) -> None:
|
||||
moonarch_wp = tmp_path / "wallpaper.jpg"
|
||||
moonarch_wp.touch()
|
||||
config = Config(background_path=None)
|
||||
with patch("moonset.config.MOONARCH_WALLPAPER", moonarch_wp):
|
||||
assert resolve_background_path(config) == moonarch_wp
|
||||
|
||||
def test_uses_package_fallback_as_last_resort(self) -> None:
|
||||
config = Config(background_path=None)
|
||||
with patch("moonset.config.MOONARCH_WALLPAPER", Path("/nonexistent")):
|
||||
result = resolve_background_path(config)
|
||||
# Package fallback should always exist
|
||||
assert result is not None
|
||||
85
tests/test_i18n.py
Normal file
85
tests/test_i18n.py
Normal file
@ -0,0 +1,85 @@
|
||||
# ABOUTME: Tests for locale detection and string lookup.
|
||||
# ABOUTME: Verifies DE/EN string tables and locale fallback behavior.
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from moonset.i18n import Strings, detect_locale, load_strings
|
||||
|
||||
|
||||
class TestDetectLocale:
|
||||
"""Tests for locale detection from environment and config files."""
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
|
||||
def test_detects_german_from_env(self) -> None:
|
||||
assert detect_locale() == "de"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "en_US.UTF-8"})
|
||||
def test_detects_english_from_env(self) -> None:
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": ""})
|
||||
def test_reads_locale_conf_when_env_empty(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "locale.conf"
|
||||
conf.write_text("LANG=de_DE.UTF-8\n")
|
||||
assert detect_locale(locale_conf_path=conf) == "de"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_reads_locale_conf_when_env_unset(self, tmp_path: Path) -> None:
|
||||
conf = tmp_path / "locale.conf"
|
||||
conf.write_text("LANG=en_GB.UTF-8\n")
|
||||
assert detect_locale(locale_conf_path=conf) == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "C"})
|
||||
def test_c_locale_falls_back_to_english(self) -> None:
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "POSIX"})
|
||||
def test_posix_locale_falls_back_to_english(self) -> None:
|
||||
assert detect_locale() == "en"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_conf_falls_back_to_english(self) -> None:
|
||||
assert detect_locale(locale_conf_path=Path("/nonexistent")) == "en"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "fr_FR.UTF-8"})
|
||||
def test_detects_unsupported_locale(self) -> None:
|
||||
assert detect_locale() == "fr"
|
||||
|
||||
|
||||
class TestLoadStrings:
|
||||
"""Tests for string table loading."""
|
||||
|
||||
def test_loads_german_strings(self) -> None:
|
||||
strings = load_strings("de")
|
||||
assert isinstance(strings, Strings)
|
||||
assert strings.lock_label == "Sperren"
|
||||
|
||||
def test_loads_english_strings(self) -> None:
|
||||
strings = load_strings("en")
|
||||
assert isinstance(strings, Strings)
|
||||
assert strings.lock_label == "Lock"
|
||||
|
||||
def test_unknown_locale_falls_back_to_english(self) -> None:
|
||||
strings = load_strings("fr")
|
||||
assert strings.lock_label == "Lock"
|
||||
|
||||
def test_all_string_fields_are_nonempty(self) -> None:
|
||||
for locale in ("de", "en"):
|
||||
strings = load_strings(locale)
|
||||
for field_name in Strings.__dataclass_fields__:
|
||||
value = getattr(strings, field_name)
|
||||
assert value, f"{locale}: {field_name} is empty"
|
||||
|
||||
def test_confirm_yes_no_present(self) -> None:
|
||||
strings = load_strings("de")
|
||||
assert strings.confirm_yes == "Ja"
|
||||
assert strings.confirm_no == "Abbrechen"
|
||||
|
||||
def test_error_messages_present(self) -> None:
|
||||
strings = load_strings("en")
|
||||
assert "failed" in strings.lock_failed.lower()
|
||||
assert "failed" in strings.logout_failed.lower()
|
||||
assert "failed" in strings.hibernate_failed.lower()
|
||||
assert "failed" in strings.reboot_failed.lower()
|
||||
assert "failed" in strings.shutdown_failed.lower()
|
||||
74
tests/test_integration.py
Normal file
74
tests/test_integration.py
Normal file
@ -0,0 +1,74 @@
|
||||
# ABOUTME: Integration tests for the moonset power menu.
|
||||
# ABOUTME: Verifies that all modules work together correctly.
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from moonset.config import Config, load_config, resolve_background_path
|
||||
from moonset.i18n import Strings, load_strings
|
||||
from moonset.panel import ACTION_DEFINITIONS, ActionDef
|
||||
from moonset.power import POWER_TIMEOUT
|
||||
|
||||
|
||||
class TestModuleIntegration:
|
||||
"""Tests that verify modules work together."""
|
||||
|
||||
def test_action_defs_reference_valid_power_functions(self) -> None:
|
||||
"""Each ActionDef references a function from power.py."""
|
||||
from moonset import power
|
||||
power_functions = {
|
||||
power.lock, power.logout, power.hibernate,
|
||||
power.reboot, power.shutdown,
|
||||
}
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert action_def.action_fn in power_functions, (
|
||||
f"{action_def.name} references unknown power function"
|
||||
)
|
||||
|
||||
def test_action_defs_match_i18n_fields_de(self) -> None:
|
||||
"""All label/error/confirm attrs in ActionDefs exist in DE strings."""
|
||||
strings = load_strings("de")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert hasattr(strings, action_def.label_attr)
|
||||
assert hasattr(strings, action_def.error_attr)
|
||||
if action_def.confirm_attr:
|
||||
assert hasattr(strings, action_def.confirm_attr)
|
||||
|
||||
def test_action_defs_match_i18n_fields_en(self) -> None:
|
||||
"""All label/error/confirm attrs in ActionDefs exist in EN strings."""
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert hasattr(strings, action_def.label_attr)
|
||||
assert hasattr(strings, action_def.error_attr)
|
||||
if action_def.confirm_attr:
|
||||
assert hasattr(strings, action_def.confirm_attr)
|
||||
|
||||
def test_config_defaults_produce_valid_background_path(self) -> None:
|
||||
"""Default config resolves to an existing wallpaper file."""
|
||||
config = Config()
|
||||
path = resolve_background_path(config)
|
||||
assert path.suffix in (".jpg", ".png", ".webp")
|
||||
|
||||
def test_full_config_to_strings_flow(self, tmp_path: Path) -> None:
|
||||
"""Config loading and string loading work independently."""
|
||||
conf = tmp_path / "moonset.toml"
|
||||
conf.write_text('background_path = "/custom/path.jpg"\n')
|
||||
config = load_config(config_paths=[conf])
|
||||
assert config.background_path == "/custom/path.jpg"
|
||||
|
||||
strings = load_strings("de")
|
||||
assert strings.lock_label == "Sperren"
|
||||
|
||||
@patch.dict("os.environ", {"LANG": "de_DE.UTF-8"})
|
||||
def test_german_locale_produces_german_labels(self) -> None:
|
||||
"""Full flow: German locale → German button labels."""
|
||||
strings = load_strings()
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
label = action_def.get_label(strings)
|
||||
assert label
|
||||
# German labels should not be the English ones
|
||||
en_strings = load_strings("en")
|
||||
en_label = action_def.get_label(en_strings)
|
||||
assert label != en_label, (
|
||||
f"{action_def.name}: DE and EN labels are identical"
|
||||
)
|
||||
68
tests/test_panel.py
Normal file
68
tests/test_panel.py
Normal file
@ -0,0 +1,68 @@
|
||||
# ABOUTME: Tests for the power menu panel UI module.
|
||||
# ABOUTME: Verifies action button creation, confirmation flow, and dismiss behavior.
|
||||
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from moonset.i18n import load_strings
|
||||
from moonset.panel import (
|
||||
ACTION_DEFINITIONS,
|
||||
ActionDef,
|
||||
)
|
||||
|
||||
|
||||
class TestActionDefinitions:
|
||||
"""Tests for action definition structure."""
|
||||
|
||||
def test_has_five_actions(self) -> None:
|
||||
assert len(ACTION_DEFINITIONS) == 5
|
||||
|
||||
def test_action_order_by_destructiveness(self) -> None:
|
||||
names = [a.name for a in ACTION_DEFINITIONS]
|
||||
assert names == ["lock", "logout", "hibernate", "reboot", "shutdown"]
|
||||
|
||||
def test_lock_has_no_confirmation(self) -> None:
|
||||
lock_def = ACTION_DEFINITIONS[0]
|
||||
assert lock_def.name == "lock"
|
||||
assert lock_def.needs_confirm is False
|
||||
|
||||
def test_destructive_actions_need_confirmation(self) -> None:
|
||||
for action_def in ACTION_DEFINITIONS[1:]:
|
||||
assert action_def.needs_confirm is True, (
|
||||
f"{action_def.name} should need confirmation"
|
||||
)
|
||||
|
||||
def test_all_actions_have_icon_names(self) -> None:
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert action_def.icon_name, f"{action_def.name} missing icon_name"
|
||||
assert action_def.icon_name.endswith("-symbolic")
|
||||
|
||||
def test_all_actions_have_callable_functions(self) -> None:
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
assert callable(action_def.action_fn)
|
||||
|
||||
def test_action_labels_from_strings(self) -> None:
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
label = action_def.get_label(strings)
|
||||
assert label, f"{action_def.name} has empty label"
|
||||
|
||||
def test_action_error_messages_from_strings(self) -> None:
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
error_msg = action_def.get_error_message(strings)
|
||||
assert error_msg, f"{action_def.name} has empty error message"
|
||||
|
||||
def test_confirmable_actions_have_confirm_prompts(self) -> None:
|
||||
strings = load_strings("en")
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
if action_def.needs_confirm:
|
||||
prompt = action_def.get_confirm_prompt(strings)
|
||||
assert prompt, f"{action_def.name} has empty confirm prompt"
|
||||
|
||||
def test_lock_confirm_prompt_is_none(self) -> None:
|
||||
strings = load_strings("en")
|
||||
lock_def = ACTION_DEFINITIONS[0]
|
||||
assert lock_def.get_confirm_prompt(strings) is None
|
||||
139
tests/test_power.py
Normal file
139
tests/test_power.py
Normal file
@ -0,0 +1,139 @@
|
||||
# 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_loginctl_lock_session(self, mock_run) -> None:
|
||||
lock()
|
||||
|
||||
mock_run.assert_called_once_with(
|
||||
["loginctl", "lock-session"], 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()
|
||||
45
uv.lock
generated
Normal file
45
uv.lock
generated
Normal file
@ -0,0 +1,45 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "moonset"
|
||||
version = "0.1.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" }
|
||||
Loading…
x
Reference in New Issue
Block a user