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:
2026-03-27 13:47:03 +01:00
commit 4cad984263
20 changed files with 1359 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# ABOUTME: Moonset package — a Wayland session power menu with GTK4.
# ABOUTME: Part of the Moonarch ecosystem.
+60
View 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

+108
View 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
View 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
View 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
View 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
View 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;
}