commit 4cad984263750e41b90218d07f53d998e99cfcb7 Author: nevaforget Date: Fri Mar 27 13:47:03 2026 +0100 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b32f0fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.venv/ +.pytest_cache/ +.pyright/ +*.egg diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a20d65 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..304248a --- /dev/null +++ b/README.md @@ -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 diff --git a/config/moonset.toml b/config/moonset.toml new file mode 100644 index 0000000..fe03dc6 --- /dev/null +++ b/config/moonset.toml @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1e086a6 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/src/moonset/__init__.py b/src/moonset/__init__.py new file mode 100644 index 0000000..6917b39 --- /dev/null +++ b/src/moonset/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Moonset package — a Wayland session power menu with GTK4. +# ABOUTME: Part of the Moonarch ecosystem. diff --git a/src/moonset/config.py b/src/moonset/config.py new file mode 100644 index 0000000..e3c5ca6 --- /dev/null +++ b/src/moonset/config.py @@ -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 diff --git a/src/moonset/data/wallpaper.jpg b/src/moonset/data/wallpaper.jpg new file mode 100644 index 0000000..86371cd Binary files /dev/null and b/src/moonset/data/wallpaper.jpg differ diff --git a/src/moonset/i18n.py b/src/moonset/i18n.py new file mode 100644 index 0000000..e67fed3 --- /dev/null +++ b/src/moonset/i18n.py @@ -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) diff --git a/src/moonset/main.py b/src/moonset/main.py new file mode 100644 index 0000000..cbd643d --- /dev/null +++ b/src/moonset/main.py @@ -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() diff --git a/src/moonset/panel.py b/src/moonset/panel.py new file mode 100644 index 0000000..bfe758d --- /dev/null +++ b/src/moonset/panel.py @@ -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) diff --git a/src/moonset/power.py b/src/moonset/power.py new file mode 100644 index 0000000..3df2367 --- /dev/null +++ b/src/moonset/power.py @@ -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) diff --git a/src/moonset/style.css b/src/moonset/style.css new file mode 100644 index 0000000..b5531af --- /dev/null +++ b/src/moonset/style.css @@ -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; +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b58f53b --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..876f6c7 --- /dev/null +++ b/tests/test_i18n.py @@ -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() diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..d79a084 --- /dev/null +++ b/tests/test_integration.py @@ -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" + ) diff --git a/tests/test_panel.py b/tests/test_panel.py new file mode 100644 index 0000000..d52fd04 --- /dev/null +++ b/tests/test_panel.py @@ -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 diff --git a/tests/test_power.py b/tests/test_power.py new file mode 100644 index 0000000..11e9f93 --- /dev/null +++ b/tests/test_power.py @@ -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() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6b2da12 --- /dev/null +++ b/uv.lock @@ -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" }