diff --git a/.gitignore b/.gitignore index 206e2cb..33ae68b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,7 @@ -__pycache__/ -*.py[cod] -*$py.class -*.egg-info/ -dist/ -build/ -.venv/ -.pytest_cache/ -.pyright/ -*.egg +/target # makepkg build artifacts pkg/src/ pkg/pkg/ pkg/*.pkg.tar* pkg/moonset/ - - -# Added by cargo - -/target diff --git a/CLAUDE.md b/CLAUDE.md index 35b97e8..2b6beef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,15 +13,12 @@ Lock, Logout, Hibernate, Reboot, Shutdown. - Rust (Edition 2024), gtk4-rs 0.11, glib 0.22 - gtk4-layer-shell 0.8 für Wayland Layer Shell (OVERLAY Layer) - `cargo test` für Unit-Tests -- Python-Quellen in `src/moonset/` als Referenz erhalten ## Projektstruktur - `src/` — Rust-Quellcode (main.rs, power.rs, i18n.rs, config.rs, users.rs, panel.rs) - `resources/` — GResource-Assets (style.css, wallpaper.jpg, default-avatar.svg) - `config/` — Beispiel-Konfigurationsdateien -- `src/moonset/` — Python-Referenzimplementierung (v0.2.0) -- `tests/` — Python-Tests (Referenz) ## Kommandos diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 1c91ab8..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,30 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "moonset" -version = "0.2.0" -description = "Wayland session power menu with GTK4 and Layer Shell" -requires-python = ">=3.11" -license = "MIT" -dependencies = [ - "PyGObject>=3.46", -] - -[project.scripts] -moonset = "moonset.main:main" - -[tool.hatch.build.targets.wheel] -packages = ["src/moonset"] - -[tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["src"] - -[tool.pyright] -pythonVersion = "3.11" -pythonPlatform = "Linux" -venvPath = "." -venv = ".venv" -typeCheckingMode = "standard" diff --git a/src/moonset/__init__.py b/src/moonset/__init__.py deleted file mode 100644 index 6917b39..0000000 --- a/src/moonset/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# 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 deleted file mode 100644 index e3c5ca6..0000000 --- a/src/moonset/config.py +++ /dev/null @@ -1,60 +0,0 @@ -# ABOUTME: Configuration loading for the session power menu. -# ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy. - -import tomllib -from dataclasses import dataclass -from importlib.resources import files -from pathlib import Path - -MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg") -PACKAGE_WALLPAPER = Path(str(files("moonset") / "data" / "wallpaper.jpg")) - -DEFAULT_CONFIG_PATHS = [ - Path("/etc/moonset/moonset.toml"), - Path.home() / ".config" / "moonset" / "moonset.toml", -] - - -@dataclass(frozen=True) -class Config: - """Power menu configuration.""" - - background_path: str | None = None - - -def load_config( - config_paths: list[Path] | None = None, -) -> Config: - """Load config from TOML file. Later paths override earlier ones.""" - if config_paths is None: - config_paths = DEFAULT_CONFIG_PATHS - - merged: dict = {} - for path in config_paths: - if path.exists(): - with open(path, "rb") as f: - data = tomllib.load(f) - merged.update(data) - - return Config( - background_path=merged.get("background_path"), - ) - - -def resolve_background_path(config: Config) -> Path: - """Resolve the wallpaper path using the fallback hierarchy. - - Priority: config background_path > Moonarch system default > package fallback. - """ - # User-configured path - if config.background_path: - path = Path(config.background_path) - if path.is_file(): - return path - - # Moonarch ecosystem default - if MOONARCH_WALLPAPER.is_file(): - return MOONARCH_WALLPAPER - - # Package fallback (always present) - return PACKAGE_WALLPAPER diff --git a/src/moonset/data/default-avatar.svg b/src/moonset/data/default-avatar.svg deleted file mode 100644 index e3da366..0000000 --- a/src/moonset/data/default-avatar.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/moonset/data/wallpaper.jpg b/src/moonset/data/wallpaper.jpg deleted file mode 100644 index 86371cd..0000000 Binary files a/src/moonset/data/wallpaper.jpg and /dev/null differ diff --git a/src/moonset/i18n.py b/src/moonset/i18n.py deleted file mode 100644 index e67fed3..0000000 --- a/src/moonset/i18n.py +++ /dev/null @@ -1,108 +0,0 @@ -# ABOUTME: Locale detection and string lookup for the power menu UI. -# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings. - -import os -from dataclasses import dataclass -from pathlib import Path - -DEFAULT_LOCALE_CONF = Path("/etc/locale.conf") - - -@dataclass(frozen=True) -class Strings: - """All user-visible strings for the power menu UI.""" - - # Button labels - lock_label: str - logout_label: str - hibernate_label: str - reboot_label: str - shutdown_label: str - - # Confirmation prompts - logout_confirm: str - hibernate_confirm: str - reboot_confirm: str - shutdown_confirm: str - - # Confirmation buttons - confirm_yes: str - confirm_no: str - - # Error messages - lock_failed: str - logout_failed: str - hibernate_failed: str - reboot_failed: str - shutdown_failed: str - - -_STRINGS_DE = Strings( - lock_label="Sperren", - logout_label="Abmelden", - hibernate_label="Ruhezustand", - reboot_label="Neustart", - shutdown_label="Herunterfahren", - logout_confirm="Wirklich abmelden?", - hibernate_confirm="Wirklich in den Ruhezustand?", - reboot_confirm="Wirklich neu starten?", - shutdown_confirm="Wirklich herunterfahren?", - confirm_yes="Ja", - confirm_no="Abbrechen", - lock_failed="Sperren fehlgeschlagen", - logout_failed="Abmelden fehlgeschlagen", - hibernate_failed="Ruhezustand fehlgeschlagen", - reboot_failed="Neustart fehlgeschlagen", - shutdown_failed="Herunterfahren fehlgeschlagen", -) - -_STRINGS_EN = Strings( - lock_label="Lock", - logout_label="Log out", - hibernate_label="Hibernate", - reboot_label="Reboot", - shutdown_label="Shut down", - logout_confirm="Really log out?", - hibernate_confirm="Really hibernate?", - reboot_confirm="Really reboot?", - shutdown_confirm="Really shut down?", - confirm_yes="Yes", - confirm_no="Cancel", - lock_failed="Lock failed", - logout_failed="Log out failed", - hibernate_failed="Hibernate failed", - reboot_failed="Reboot failed", - shutdown_failed="Shutdown failed", -) - -_LOCALE_MAP: dict[str, Strings] = { - "de": _STRINGS_DE, - "en": _STRINGS_EN, -} - - -def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str: - """Determine the system language from LANG env var or /etc/locale.conf.""" - lang = os.environ.get("LANG") - - if not lang and locale_conf_path.exists(): - for line in locale_conf_path.read_text().splitlines(): - if line.startswith("LANG="): - lang = line.split("=", 1)[1].strip() - break - - if not lang or lang in ("C", "POSIX"): - return "en" - - # Extract language prefix: "de_DE.UTF-8" → "de" - lang = lang.split("_")[0].split(".")[0].lower() - if not lang.isalpha(): - return "en" - return lang - - -def load_strings(locale: str | None = None) -> Strings: - """Return the string table for the given locale, defaulting to English.""" - if locale is None: - locale = detect_locale() - return _LOCALE_MAP.get(locale, _STRINGS_EN) diff --git a/src/moonset/main.py b/src/moonset/main.py deleted file mode 100644 index bb9b750..0000000 --- a/src/moonset/main.py +++ /dev/null @@ -1,138 +0,0 @@ -# ABOUTME: Entry point for Moonset — sets up GTK Application and Layer Shell. -# ABOUTME: Handles multi-monitor setup: power menu on primary, wallpaper on secondary monitors. - -import logging -import os -import sys -from importlib.resources import files - -# gtk4-layer-shell must be loaded before libwayland-client. -# Only allow our own library in LD_PRELOAD — discard anything inherited from the environment. -_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so" -_existing_preload = os.environ.get("LD_PRELOAD", "") -_is_testing = "pytest" in sys.modules or "unittest" in sys.modules -if ( - not _is_testing - and _LAYER_SHELL_LIB not in _existing_preload - and os.path.exists(_LAYER_SHELL_LIB) -): - os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB - os.execvp(sys.executable, [sys.executable, "-m", "moonset.main"] + sys.argv[1:]) - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk - -from moonset.config import load_config, resolve_background_path -from moonset.panel import PanelWindow, WallpaperWindow - -# gtk4-layer-shell is optional for development/testing -try: - gi.require_version("Gtk4LayerShell", "1.0") - from gi.repository import Gtk4LayerShell - HAS_LAYER_SHELL = True -except (ValueError, ImportError): - HAS_LAYER_SHELL = False - -logger = logging.getLogger(__name__) - - -def _setup_logging() -> None: - """Configure logging to stderr.""" - root = logging.getLogger() - root.setLevel(logging.INFO) - - formatter = logging.Formatter( - "%(asctime)s %(levelname)s %(name)s: %(message)s" - ) - - stderr_handler = logging.StreamHandler(sys.stderr) - stderr_handler.setLevel(logging.INFO) - stderr_handler.setFormatter(formatter) - root.addHandler(stderr_handler) - - -class MoonsetApp(Gtk.Application): - """GTK Application for the Moonset power menu.""" - - def __init__(self) -> None: - super().__init__(application_id="dev.moonarch.moonset") - - def do_activate(self) -> None: - """Create and present power menu windows on all monitors.""" - display = Gdk.Display.get_default() - if display is None: - logger.error("No display available — cannot start power menu UI") - return - - self._load_css(display) - - # Resolve wallpaper once, share across all windows - config = load_config() - bg_path = resolve_background_path(config) - - # Panel on focused output (no set_monitor → compositor picks focused) - panel = PanelWindow(bg_path=bg_path, application=self) - if HAS_LAYER_SHELL: - self._setup_layer_shell(panel, keyboard=True) - panel.present() - - # Wallpaper on all other monitors - monitors = display.get_monitors() - for i in range(monitors.get_n_items()): - monitor = monitors.get_item(i) - wallpaper = WallpaperWindow(bg_path=bg_path, application=self) - if HAS_LAYER_SHELL: - self._setup_layer_shell( - wallpaper, keyboard=False, - layer=Gtk4LayerShell.Layer.TOP, - ) - Gtk4LayerShell.set_monitor(wallpaper, monitor) - wallpaper.present() - - def _load_css(self, display: Gdk.Display) -> None: - """Load the CSS stylesheet for the power menu.""" - css_provider = Gtk.CssProvider() - css_path = files("moonset") / "style.css" - css_provider.load_from_path(str(css_path)) - Gtk.StyleContext.add_provider_for_display( - display, - css_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - def _setup_layer_shell( - self, window: Gtk.Window, keyboard: bool = False, - layer: int | None = None, - ) -> None: - """Configure gtk4-layer-shell for fullscreen display.""" - Gtk4LayerShell.init_for_window(window) - Gtk4LayerShell.set_layer( - window, layer if layer is not None else Gtk4LayerShell.Layer.OVERLAY - ) - Gtk4LayerShell.set_exclusive_zone(window, -1) - if keyboard: - Gtk4LayerShell.set_keyboard_mode( - window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE - ) - # Anchor to all edges for fullscreen - for edge in [ - Gtk4LayerShell.Edge.TOP, - Gtk4LayerShell.Edge.BOTTOM, - Gtk4LayerShell.Edge.LEFT, - Gtk4LayerShell.Edge.RIGHT, - ]: - Gtk4LayerShell.set_anchor(window, edge, True) - - -def main() -> None: - """Run the Moonset application.""" - _setup_logging() - logger.info("Moonset starting") - app = MoonsetApp() - app.run(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/src/moonset/panel.py b/src/moonset/panel.py deleted file mode 100644 index ea7cd63..0000000 --- a/src/moonset/panel.py +++ /dev/null @@ -1,395 +0,0 @@ -# ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows. -# ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors). - -import logging -import threading -from collections.abc import Callable -from dataclasses import dataclass -from pathlib import Path - -import gi -gi.require_version("Gtk", "4.0") -gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk, GdkPixbuf, GLib - -from moonset.i18n import Strings, load_strings -from moonset.users import get_current_user, get_avatar_path, get_default_avatar_path -from moonset import power - -logger = logging.getLogger(__name__) - -AVATAR_SIZE = 128 - - -@dataclass(frozen=True) -class ActionDef: - """Definition for a single power action button.""" - - name: str - icon_name: str - needs_confirm: bool - action_fn: Callable[[], None] - label_attr: str - error_attr: str - confirm_attr: str | None - - def get_label(self, strings: Strings) -> str: - return getattr(strings, self.label_attr) - - def get_error_message(self, strings: Strings) -> str: - return getattr(strings, self.error_attr) - - def get_confirm_prompt(self, strings: Strings) -> str | None: - if self.confirm_attr is None: - return None - return getattr(strings, self.confirm_attr) - - -ACTION_DEFINITIONS: list[ActionDef] = [ - ActionDef( - name="lock", - icon_name="system-lock-screen-symbolic", - needs_confirm=False, - action_fn=power.lock, - label_attr="lock_label", - error_attr="lock_failed", - confirm_attr=None, - ), - ActionDef( - name="logout", - icon_name="system-log-out-symbolic", - needs_confirm=True, - action_fn=power.logout, - label_attr="logout_label", - error_attr="logout_failed", - confirm_attr="logout_confirm", - ), - ActionDef( - name="hibernate", - icon_name="system-hibernate-symbolic", - needs_confirm=True, - action_fn=power.hibernate, - label_attr="hibernate_label", - error_attr="hibernate_failed", - confirm_attr="hibernate_confirm", - ), - ActionDef( - name="reboot", - icon_name="system-reboot-symbolic", - needs_confirm=True, - action_fn=power.reboot, - label_attr="reboot_label", - error_attr="reboot_failed", - confirm_attr="reboot_confirm", - ), - ActionDef( - name="shutdown", - icon_name="system-shutdown-symbolic", - needs_confirm=True, - action_fn=power.shutdown, - label_attr="shutdown_label", - error_attr="shutdown_failed", - confirm_attr="shutdown_confirm", - ), -] - - -class WallpaperWindow(Gtk.ApplicationWindow): - """Fullscreen wallpaper-only window for secondary monitors.""" - - def __init__(self, bg_path: Path, application: Gtk.Application) -> None: - super().__init__(application=application) - self.add_css_class("wallpaper") - - background = Gtk.Picture.new_for_filename(str(bg_path)) - background.set_content_fit(Gtk.ContentFit.COVER) - background.set_hexpand(True) - background.set_vexpand(True) - self.set_child(background) - - self.connect("map", self._on_map) - - def _on_map(self, widget: Gtk.Widget) -> None: - """Trigger fade-in once the window is visible.""" - GLib.idle_add(lambda: self.add_css_class("visible") or GLib.SOURCE_REMOVE) - - -class PanelWindow(Gtk.ApplicationWindow): - """Fullscreen power menu window for the primary monitor.""" - - def __init__(self, bg_path: Path, application: Gtk.Application) -> None: - super().__init__(application=application) - self.add_css_class("panel") - - self._strings = load_strings() - self._app = application - self._user = get_current_user() - self._confirm_box: Gtk.Box | None = None - - self._build_ui(bg_path) - self._setup_keyboard() - - # Focus the first action button once the window is mapped - self.connect("map", self._on_map) - - def _build_ui(self, bg_path: Path) -> None: - """Build the panel layout with wallpaper background and action buttons.""" - # Main overlay for background + centered content - overlay = Gtk.Overlay() - self.set_child(overlay) - - # Background wallpaper - background = Gtk.Picture.new_for_filename(str(bg_path)) - background.set_content_fit(Gtk.ContentFit.COVER) - background.set_hexpand(True) - background.set_vexpand(True) - overlay.set_child(background) - - # Click on background dismisses the menu - click_controller = Gtk.GestureClick() - click_controller.connect("released", self._on_background_click) - background.add_controller(click_controller) - - # Centered content box - content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - content_box.set_halign(Gtk.Align.CENTER) - content_box.set_valign(Gtk.Align.CENTER) - overlay.add_overlay(content_box) - - # Avatar - avatar_frame = Gtk.Box() - avatar_frame.set_size_request(AVATAR_SIZE, AVATAR_SIZE) - avatar_frame.set_halign(Gtk.Align.CENTER) - avatar_frame.set_overflow(Gtk.Overflow.HIDDEN) - avatar_frame.add_css_class("avatar") - self._avatar_image = Gtk.Image() - self._avatar_image.set_pixel_size(AVATAR_SIZE) - avatar_frame.append(self._avatar_image) - content_box.append(avatar_frame) - - avatar_path = get_avatar_path(self._user.home, self._user.username) - if avatar_path: - self._set_avatar_from_file(avatar_path) - else: - self._set_default_avatar() - - # Username label - username_label = Gtk.Label(label=self._user.display_name) - username_label.add_css_class("username-label") - content_box.append(username_label) - - # Action buttons row - self._button_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, spacing=24 - ) - self._button_box.set_halign(Gtk.Align.CENTER) - content_box.append(self._button_box) - - for action_def in ACTION_DEFINITIONS: - button = self._create_action_button(action_def) - self._button_box.append(button) - - # Confirmation area (below buttons) - self._confirm_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self._confirm_area.set_halign(Gtk.Align.CENTER) - self._confirm_area.set_margin_top(24) - content_box.append(self._confirm_area) - - # Error label - self._error_label = Gtk.Label() - self._error_label.add_css_class("error-label") - self._error_label.set_visible(False) - self._error_label.set_margin_top(16) - content_box.append(self._error_label) - - def _create_action_button(self, action_def: ActionDef) -> Gtk.Button: - """Create a single action button with icon and label.""" - button_content = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, spacing=4 - ) - button_content.set_halign(Gtk.Align.CENTER) - button_content.set_valign(Gtk.Align.CENTER) - - # Look up the 22px icon variant (matches moonlock), render at 64px - display = Gdk.Display.get_default() - theme = Gtk.IconTheme.get_for_display(display) - icon_paintable = theme.lookup_icon( - action_def.icon_name, None, 22, 1, - Gtk.TextDirection.NONE, Gtk.IconLookupFlags.FORCE_SYMBOLIC, - ) - icon_file = icon_paintable.get_file() - icon = Gtk.Image() - if icon_file: - # Load the SVG at 64px via GdkPixbuf - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - icon_file.get_path(), 64, 64, True - ) - icon.set_from_pixbuf(pixbuf) - else: - icon.set_from_icon_name(action_def.icon_name) - icon.set_pixel_size(64) - icon.add_css_class("action-icon") - button_content.append(icon) - - label = Gtk.Label(label=action_def.get_label(self._strings)) - label.add_css_class("action-label") - button_content.append(label) - - button = Gtk.Button() - button.set_child(button_content) - button.add_css_class("action-button") - button.connect("clicked", lambda _, ad=action_def: self._on_action_clicked(ad)) - return button - - def _setup_keyboard(self) -> None: - """Set up keyboard event handling — Escape dismisses.""" - controller = Gtk.EventControllerKey() - controller.connect("key-pressed", self._on_key_pressed) - self.add_controller(controller) - - def _on_map(self, widget: Gtk.Widget) -> None: - """Trigger fade-in and grab focus once the window is visible.""" - GLib.idle_add(self._fade_in) - - def _fade_in(self) -> bool: - """Add visible class to trigger CSS opacity transition, then grab focus.""" - self.add_css_class("visible") - GLib.idle_add(self._grab_initial_focus) - return GLib.SOURCE_REMOVE - - def _grab_initial_focus(self) -> bool: - """Set focus on the first action button.""" - first_button = self._button_box.get_first_child() - if first_button is not None: - first_button.grab_focus() - return GLib.SOURCE_REMOVE - - def _on_key_pressed( - self, - controller: Gtk.EventControllerKey, - keyval: int, - keycode: int, - state: Gdk.ModifierType, - ) -> bool: - """Handle key presses — Escape dismisses the power menu.""" - if keyval == Gdk.KEY_Escape: - self._app.quit() - return True - return False - - def _on_background_click( - self, - gesture: Gtk.GestureClick, - n_press: int, - x: float, - y: float, - ) -> None: - """Dismiss the power menu when background is clicked.""" - self._app.quit() - - def _on_action_clicked(self, action_def: ActionDef) -> None: - """Handle an action button click.""" - self._dismiss_confirm() - self._error_label.set_visible(False) - - if not action_def.needs_confirm: - self._execute_action(action_def) - return - - self._show_confirm(action_def) - - def _show_confirm(self, action_def: ActionDef) -> None: - """Show inline confirmation below the action buttons.""" - self._confirm_box = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, spacing=8 - ) - self._confirm_box.set_halign(Gtk.Align.CENTER) - self._confirm_box.add_css_class("confirm-box") - - prompt = action_def.get_confirm_prompt(self._strings) - confirm_label = Gtk.Label(label=prompt) - confirm_label.add_css_class("confirm-label") - self._confirm_box.append(confirm_label) - - button_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, spacing=8 - ) - button_box.set_halign(Gtk.Align.CENTER) - - yes_btn = Gtk.Button(label=self._strings.confirm_yes) - yes_btn.add_css_class("confirm-yes") - yes_btn.connect( - "clicked", lambda _: self._execute_action(action_def) - ) - button_box.append(yes_btn) - - no_btn = Gtk.Button(label=self._strings.confirm_no) - no_btn.add_css_class("confirm-no") - no_btn.connect("clicked", lambda _: self._dismiss_confirm()) - button_box.append(no_btn) - - self._confirm_box.append(button_box) - self._confirm_area.append(self._confirm_box) - - # Focus the "No" button — safe default for keyboard navigation - no_btn.grab_focus() - - def _dismiss_confirm(self) -> None: - """Remove the confirmation prompt.""" - if self._confirm_box is not None: - self._confirm_area.remove(self._confirm_box) - self._confirm_box = None - - def _execute_action(self, action_def: ActionDef) -> None: - """Execute a power action in a background thread.""" - self._dismiss_confirm() - - def _run() -> None: - try: - action_def.action_fn() - # Lock action: quit after successful execution - if action_def.name == "lock": - GLib.idle_add(self._app.quit) - except Exception: - logger.exception("Power action '%s' failed", action_def.name) - error_msg = action_def.get_error_message(self._strings) - GLib.idle_add(self._show_error, error_msg) - - thread = threading.Thread(target=_run, daemon=True) - thread.start() - - def _set_avatar_from_file(self, path: Path) -> None: - """Load an image file and set it as the avatar.""" - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( - str(path), AVATAR_SIZE, AVATAR_SIZE, True - ) - self._avatar_image.set_from_pixbuf(pixbuf) - except GLib.Error: - self._set_default_avatar() - - def _set_default_avatar(self) -> None: - """Load the default avatar SVG, tinted with the foreground color.""" - try: - default_path = get_default_avatar_path() - svg_text = default_path.read_text() - rgba = self.get_color() - fg_color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" - svg_text = svg_text.replace("#PLACEHOLDER", fg_color) - svg_bytes = svg_text.encode("utf-8") - loader = GdkPixbuf.PixbufLoader.new_with_type("svg") - loader.set_size(AVATAR_SIZE, AVATAR_SIZE) - loader.write(svg_bytes) - loader.close() - pixbuf = loader.get_pixbuf() - if pixbuf: - self._avatar_image.set_from_pixbuf(pixbuf) - return - except (GLib.Error, OSError): - pass - self._avatar_image.set_from_icon_name("avatar-default-symbolic") - - def _show_error(self, message: str) -> None: - """Display an error message.""" - self._error_label.set_text(message) - self._error_label.set_visible(True) diff --git a/src/moonset/power.py b/src/moonset/power.py deleted file mode 100644 index 7f30f09..0000000 --- a/src/moonset/power.py +++ /dev/null @@ -1,34 +0,0 @@ -# ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown. -# ABOUTME: Simple wrappers around system commands for the session power menu. - -import subprocess - - -POWER_TIMEOUT = 30 - - -def lock() -> None: - """Lock the current session by launching moonlock.""" - subprocess.run(["moonlock"], check=True, timeout=POWER_TIMEOUT) - - -def logout() -> None: - """Quit the Niri compositor (logout).""" - subprocess.run( - ["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT - ) - - -def hibernate() -> None: - """Hibernate the system via systemctl.""" - subprocess.run(["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT) - - -def reboot() -> None: - """Reboot the system via loginctl.""" - subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT) - - -def shutdown() -> None: - """Shut down the system via loginctl.""" - subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT) diff --git a/src/moonset/style.css b/src/moonset/style.css deleted file mode 100644 index fd9283b..0000000 --- a/src/moonset/style.css +++ /dev/null @@ -1,105 +0,0 @@ -/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */ -/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */ - -/* Main panel window background */ -window.panel { - background-color: @theme_bg_color; - background-size: cover; - background-position: center; -} - -/* Wallpaper-only window for secondary monitors */ -window.wallpaper { - background-color: @theme_bg_color; -} - -/* Round avatar image */ -.avatar { - border-radius: 50%; - min-width: 128px; - min-height: 128px; - background-color: @theme_selected_bg_color; - border: 3px solid alpha(white, 0.3); -} - -/* Username label */ -.username-label { - font-size: 24px; - font-weight: bold; - color: white; - margin-top: 12px; - margin-bottom: 40px; -} - -/* Action button — square card */ -.action-button { - min-width: 120px; - min-height: 120px; - padding: 16px; - border-radius: 50%; - background-color: alpha(@theme_base_color, 0.55); - color: @theme_fg_color; - border: none; -} - -.action-button:hover { - background-color: alpha(@theme_base_color, 0.7); -} - -/* Action icon inside button — request 48px from theme, scale up via CSS */ -.action-icon { - color: @theme_fg_color; - -gtk-icon-size: 64px; -} - -/* Action label below icon */ -.action-label { - font-size: 14px; - color: @theme_unfocused_fg_color; -} - -/* Confirmation box below action buttons */ -.confirm-box { - padding: 16px 24px; - background-color: transparent; -} - -/* Confirmation prompt text */ -.confirm-label { - font-size: 16px; - color: @theme_fg_color; - margin-bottom: 4px; -} - -/* Confirm "Yes" button */ -.confirm-yes { - padding: 8px 24px; - border-radius: 8px; - background-color: @error_color; - color: @theme_bg_color; - border: none; - font-weight: bold; -} - -.confirm-yes:hover { - background-color: lighter(@error_color); -} - -/* Confirm "No/Cancel" button */ -.confirm-no { - padding: 8px 24px; - border-radius: 8px; - background-color: @theme_unfocused_bg_color; - color: @theme_fg_color; - border: none; -} - -.confirm-no:hover { - background-color: @theme_selected_bg_color; -} - -/* Error message label */ -.error-label { - color: @error_color; - font-size: 14px; -} diff --git a/src/moonset/users.py b/src/moonset/users.py deleted file mode 100644 index abc2d05..0000000 --- a/src/moonset/users.py +++ /dev/null @@ -1,65 +0,0 @@ -# ABOUTME: Current user detection and avatar loading for the power menu. -# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face). - -import os -import pwd -from dataclasses import dataclass -from importlib.resources import files -from pathlib import Path - -DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons") - - -@dataclass(frozen=True) -class User: - """Represents the current user for the power menu.""" - - username: str - display_name: str - home: Path - uid: int - - -def get_current_user() -> User: - """Get the currently logged-in user's info from the system.""" - # Use getuid() instead of getlogin() — getlogin() fails without a controlling - # terminal (systemd units, display-manager-started sessions). - pw = pwd.getpwuid(os.getuid()) - - gecos = pw.pw_gecos - # GECOS field may contain comma-separated values; first field is the full name - display_name = gecos.split(",")[0] if gecos else pw.pw_name - if not display_name: - display_name = pw.pw_name - - return User( - username=pw.pw_name, - display_name=display_name, - home=Path(pw.pw_dir), - uid=pw.pw_uid, - ) - - -def get_avatar_path( - home: Path, - username: str | None = None, - accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR, -) -> Path | None: - """Find the user's avatar image, checking ~/.face then AccountsService.""" - # ~/.face takes priority - face = home / ".face" - if face.exists(): - return face - - # AccountsService icon - if username and accountsservice_dir.exists(): - icon = accountsservice_dir / username - if icon.exists(): - return icon - - return None - - -def get_default_avatar_path() -> Path: - """Return the path to the package default avatar SVG.""" - return Path(str(files("moonset") / "data" / "default-avatar.svg")) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index b58f53b..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,71 +0,0 @@ -# ABOUTME: Tests for configuration loading and wallpaper path resolution. -# ABOUTME: Verifies TOML parsing, fallback hierarchy, and default values. - -from pathlib import Path -from unittest.mock import patch - -import pytest - -from moonset.config import Config, load_config, resolve_background_path - - -class TestLoadConfig: - """Tests for TOML config loading.""" - - def test_returns_default_config_when_no_files_exist(self) -> None: - config = load_config(config_paths=[Path("/nonexistent")]) - assert config.background_path is None - - def test_reads_background_path_from_toml(self, tmp_path: Path) -> None: - conf = tmp_path / "moonset.toml" - conf.write_text('background_path = "/custom/wallpaper.jpg"\n') - config = load_config(config_paths=[conf]) - assert config.background_path == "/custom/wallpaper.jpg" - - def test_later_paths_override_earlier(self, tmp_path: Path) -> None: - conf1 = tmp_path / "first.toml" - conf1.write_text('background_path = "/first.jpg"\n') - conf2 = tmp_path / "second.toml" - conf2.write_text('background_path = "/second.jpg"\n') - config = load_config(config_paths=[conf1, conf2]) - assert config.background_path == "/second.jpg" - - def test_skips_missing_config_files(self, tmp_path: Path) -> None: - conf = tmp_path / "exists.toml" - conf.write_text('background_path = "/exists.jpg"\n') - config = load_config(config_paths=[Path("/nonexistent"), conf]) - assert config.background_path == "/exists.jpg" - - def test_default_config_has_none_background(self) -> None: - config = Config() - assert config.background_path is None - - -class TestResolveBackgroundPath: - """Tests for wallpaper path resolution fallback hierarchy.""" - - def test_uses_config_path_when_file_exists(self, tmp_path: Path) -> None: - wallpaper = tmp_path / "custom.jpg" - wallpaper.touch() - config = Config(background_path=str(wallpaper)) - assert resolve_background_path(config) == wallpaper - - def test_ignores_config_path_when_file_missing(self, tmp_path: Path) -> None: - config = Config(background_path="/nonexistent/wallpaper.jpg") - # Falls through to system or package fallback - result = resolve_background_path(config) - assert result is not None - - def test_uses_moonarch_wallpaper_as_second_fallback(self, tmp_path: Path) -> None: - moonarch_wp = tmp_path / "wallpaper.jpg" - moonarch_wp.touch() - config = Config(background_path=None) - with patch("moonset.config.MOONARCH_WALLPAPER", moonarch_wp): - assert resolve_background_path(config) == moonarch_wp - - def test_uses_package_fallback_as_last_resort(self) -> None: - config = Config(background_path=None) - with patch("moonset.config.MOONARCH_WALLPAPER", Path("/nonexistent")): - result = resolve_background_path(config) - # Package fallback should always exist - assert result is not None diff --git a/tests/test_i18n.py b/tests/test_i18n.py deleted file mode 100644 index 876f6c7..0000000 --- a/tests/test_i18n.py +++ /dev/null @@ -1,85 +0,0 @@ -# ABOUTME: Tests for locale detection and string lookup. -# ABOUTME: Verifies DE/EN string tables and locale fallback behavior. - -from pathlib import Path -from unittest.mock import patch - -from moonset.i18n import Strings, detect_locale, load_strings - - -class TestDetectLocale: - """Tests for locale detection from environment and config files.""" - - @patch.dict("os.environ", {"LANG": "de_DE.UTF-8"}) - def test_detects_german_from_env(self) -> None: - assert detect_locale() == "de" - - @patch.dict("os.environ", {"LANG": "en_US.UTF-8"}) - def test_detects_english_from_env(self) -> None: - assert detect_locale() == "en" - - @patch.dict("os.environ", {"LANG": ""}) - def test_reads_locale_conf_when_env_empty(self, tmp_path: Path) -> None: - conf = tmp_path / "locale.conf" - conf.write_text("LANG=de_DE.UTF-8\n") - assert detect_locale(locale_conf_path=conf) == "de" - - @patch.dict("os.environ", {}, clear=True) - def test_reads_locale_conf_when_env_unset(self, tmp_path: Path) -> None: - conf = tmp_path / "locale.conf" - conf.write_text("LANG=en_GB.UTF-8\n") - assert detect_locale(locale_conf_path=conf) == "en" - - @patch.dict("os.environ", {"LANG": "C"}) - def test_c_locale_falls_back_to_english(self) -> None: - assert detect_locale() == "en" - - @patch.dict("os.environ", {"LANG": "POSIX"}) - def test_posix_locale_falls_back_to_english(self) -> None: - assert detect_locale() == "en" - - @patch.dict("os.environ", {}, clear=True) - def test_missing_conf_falls_back_to_english(self) -> None: - assert detect_locale(locale_conf_path=Path("/nonexistent")) == "en" - - @patch.dict("os.environ", {"LANG": "fr_FR.UTF-8"}) - def test_detects_unsupported_locale(self) -> None: - assert detect_locale() == "fr" - - -class TestLoadStrings: - """Tests for string table loading.""" - - def test_loads_german_strings(self) -> None: - strings = load_strings("de") - assert isinstance(strings, Strings) - assert strings.lock_label == "Sperren" - - def test_loads_english_strings(self) -> None: - strings = load_strings("en") - assert isinstance(strings, Strings) - assert strings.lock_label == "Lock" - - def test_unknown_locale_falls_back_to_english(self) -> None: - strings = load_strings("fr") - assert strings.lock_label == "Lock" - - def test_all_string_fields_are_nonempty(self) -> None: - for locale in ("de", "en"): - strings = load_strings(locale) - for field_name in Strings.__dataclass_fields__: - value = getattr(strings, field_name) - assert value, f"{locale}: {field_name} is empty" - - def test_confirm_yes_no_present(self) -> None: - strings = load_strings("de") - assert strings.confirm_yes == "Ja" - assert strings.confirm_no == "Abbrechen" - - def test_error_messages_present(self) -> None: - strings = load_strings("en") - assert "failed" in strings.lock_failed.lower() - assert "failed" in strings.logout_failed.lower() - assert "failed" in strings.hibernate_failed.lower() - assert "failed" in strings.reboot_failed.lower() - assert "failed" in strings.shutdown_failed.lower() diff --git a/tests/test_integration.py b/tests/test_integration.py deleted file mode 100644 index d79a084..0000000 --- a/tests/test_integration.py +++ /dev/null @@ -1,74 +0,0 @@ -# ABOUTME: Integration tests for the moonset power menu. -# ABOUTME: Verifies that all modules work together correctly. - -from pathlib import Path -from unittest.mock import patch - -from moonset.config import Config, load_config, resolve_background_path -from moonset.i18n import Strings, load_strings -from moonset.panel import ACTION_DEFINITIONS, ActionDef -from moonset.power import POWER_TIMEOUT - - -class TestModuleIntegration: - """Tests that verify modules work together.""" - - def test_action_defs_reference_valid_power_functions(self) -> None: - """Each ActionDef references a function from power.py.""" - from moonset import power - power_functions = { - power.lock, power.logout, power.hibernate, - power.reboot, power.shutdown, - } - for action_def in ACTION_DEFINITIONS: - assert action_def.action_fn in power_functions, ( - f"{action_def.name} references unknown power function" - ) - - def test_action_defs_match_i18n_fields_de(self) -> None: - """All label/error/confirm attrs in ActionDefs exist in DE strings.""" - strings = load_strings("de") - for action_def in ACTION_DEFINITIONS: - assert hasattr(strings, action_def.label_attr) - assert hasattr(strings, action_def.error_attr) - if action_def.confirm_attr: - assert hasattr(strings, action_def.confirm_attr) - - def test_action_defs_match_i18n_fields_en(self) -> None: - """All label/error/confirm attrs in ActionDefs exist in EN strings.""" - strings = load_strings("en") - for action_def in ACTION_DEFINITIONS: - assert hasattr(strings, action_def.label_attr) - assert hasattr(strings, action_def.error_attr) - if action_def.confirm_attr: - assert hasattr(strings, action_def.confirm_attr) - - def test_config_defaults_produce_valid_background_path(self) -> None: - """Default config resolves to an existing wallpaper file.""" - config = Config() - path = resolve_background_path(config) - assert path.suffix in (".jpg", ".png", ".webp") - - def test_full_config_to_strings_flow(self, tmp_path: Path) -> None: - """Config loading and string loading work independently.""" - conf = tmp_path / "moonset.toml" - conf.write_text('background_path = "/custom/path.jpg"\n') - config = load_config(config_paths=[conf]) - assert config.background_path == "/custom/path.jpg" - - strings = load_strings("de") - assert strings.lock_label == "Sperren" - - @patch.dict("os.environ", {"LANG": "de_DE.UTF-8"}) - def test_german_locale_produces_german_labels(self) -> None: - """Full flow: German locale → German button labels.""" - strings = load_strings() - for action_def in ACTION_DEFINITIONS: - label = action_def.get_label(strings) - assert label - # German labels should not be the English ones - en_strings = load_strings("en") - en_label = action_def.get_label(en_strings) - assert label != en_label, ( - f"{action_def.name}: DE and EN labels are identical" - ) diff --git a/tests/test_panel.py b/tests/test_panel.py deleted file mode 100644 index d52fd04..0000000 --- a/tests/test_panel.py +++ /dev/null @@ -1,68 +0,0 @@ -# ABOUTME: Tests for the power menu panel UI module. -# ABOUTME: Verifies action button creation, confirmation flow, and dismiss behavior. - -from unittest.mock import MagicMock, patch, PropertyMock -import subprocess - -import pytest - -from moonset.i18n import load_strings -from moonset.panel import ( - ACTION_DEFINITIONS, - ActionDef, -) - - -class TestActionDefinitions: - """Tests for action definition structure.""" - - def test_has_five_actions(self) -> None: - assert len(ACTION_DEFINITIONS) == 5 - - def test_action_order_by_destructiveness(self) -> None: - names = [a.name for a in ACTION_DEFINITIONS] - assert names == ["lock", "logout", "hibernate", "reboot", "shutdown"] - - def test_lock_has_no_confirmation(self) -> None: - lock_def = ACTION_DEFINITIONS[0] - assert lock_def.name == "lock" - assert lock_def.needs_confirm is False - - def test_destructive_actions_need_confirmation(self) -> None: - for action_def in ACTION_DEFINITIONS[1:]: - assert action_def.needs_confirm is True, ( - f"{action_def.name} should need confirmation" - ) - - def test_all_actions_have_icon_names(self) -> None: - for action_def in ACTION_DEFINITIONS: - assert action_def.icon_name, f"{action_def.name} missing icon_name" - assert action_def.icon_name.endswith("-symbolic") - - def test_all_actions_have_callable_functions(self) -> None: - for action_def in ACTION_DEFINITIONS: - assert callable(action_def.action_fn) - - def test_action_labels_from_strings(self) -> None: - strings = load_strings("en") - for action_def in ACTION_DEFINITIONS: - label = action_def.get_label(strings) - assert label, f"{action_def.name} has empty label" - - def test_action_error_messages_from_strings(self) -> None: - strings = load_strings("en") - for action_def in ACTION_DEFINITIONS: - error_msg = action_def.get_error_message(strings) - assert error_msg, f"{action_def.name} has empty error message" - - def test_confirmable_actions_have_confirm_prompts(self) -> None: - strings = load_strings("en") - for action_def in ACTION_DEFINITIONS: - if action_def.needs_confirm: - prompt = action_def.get_confirm_prompt(strings) - assert prompt, f"{action_def.name} has empty confirm prompt" - - def test_lock_confirm_prompt_is_none(self) -> None: - strings = load_strings("en") - lock_def = ACTION_DEFINITIONS[0] - assert lock_def.get_confirm_prompt(strings) is None diff --git a/tests/test_power.py b/tests/test_power.py deleted file mode 100644 index 18db985..0000000 --- a/tests/test_power.py +++ /dev/null @@ -1,139 +0,0 @@ -# ABOUTME: Tests for power actions — lock, logout, hibernate, reboot, shutdown. -# ABOUTME: Uses mocking to avoid actually calling system commands. - -import subprocess -from unittest.mock import patch - -import pytest - -from moonset.power import lock, logout, hibernate, reboot, shutdown, POWER_TIMEOUT - - -class TestLock: - """Tests for the lock power action.""" - - @patch("moonset.power.subprocess.run") - def test_calls_moonlock(self, mock_run) -> None: - lock() - - mock_run.assert_called_once_with( - ["moonlock"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moonset.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl") - - with pytest.raises(subprocess.CalledProcessError): - lock() - - @patch("moonset.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - lock() - - -class TestLogout: - """Tests for the logout power action.""" - - @patch("moonset.power.subprocess.run") - def test_calls_niri_quit(self, mock_run) -> None: - logout() - - mock_run.assert_called_once_with( - ["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moonset.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "niri") - - with pytest.raises(subprocess.CalledProcessError): - logout() - - @patch("moonset.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("niri", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - logout() - - -class TestHibernate: - """Tests for the hibernate power action.""" - - @patch("moonset.power.subprocess.run") - def test_calls_systemctl_hibernate(self, mock_run) -> None: - hibernate() - - mock_run.assert_called_once_with( - ["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moonset.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "systemctl") - - with pytest.raises(subprocess.CalledProcessError): - hibernate() - - @patch("moonset.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("systemctl", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - hibernate() - - -class TestReboot: - """Tests for the reboot power action.""" - - @patch("moonset.power.subprocess.run") - def test_calls_loginctl_reboot(self, mock_run) -> None: - reboot() - - mock_run.assert_called_once_with( - ["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moonset.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl") - - with pytest.raises(subprocess.CalledProcessError): - reboot() - - @patch("moonset.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - reboot() - - -class TestShutdown: - """Tests for the shutdown power action.""" - - @patch("moonset.power.subprocess.run") - def test_calls_loginctl_poweroff(self, mock_run) -> None: - shutdown() - - mock_run.assert_called_once_with( - ["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT - ) - - @patch("moonset.power.subprocess.run") - def test_raises_on_failure(self, mock_run) -> None: - mock_run.side_effect = subprocess.CalledProcessError(1, "loginctl") - - with pytest.raises(subprocess.CalledProcessError): - shutdown() - - @patch("moonset.power.subprocess.run") - def test_raises_on_timeout(self, mock_run) -> None: - mock_run.side_effect = subprocess.TimeoutExpired("loginctl", POWER_TIMEOUT) - - with pytest.raises(subprocess.TimeoutExpired): - shutdown() diff --git a/tests/test_users.py b/tests/test_users.py deleted file mode 100644 index b89befc..0000000 --- a/tests/test_users.py +++ /dev/null @@ -1,95 +0,0 @@ -# ABOUTME: Tests for current user detection and avatar loading. -# ABOUTME: Verifies user info retrieval from the system. - -from pathlib import Path -from unittest.mock import patch - -from moonset.users import get_current_user, get_avatar_path, get_default_avatar_path, User - - -class TestGetCurrentUser: - """Tests for current user detection.""" - - @patch("moonset.users.os.getuid", return_value=1000) - @patch("moonset.users.pwd.getpwuid") - def test_returns_user_with_correct_username(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "Test User" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.username == "testuser" - assert user.display_name == "Test User" - assert user.home == Path("/home/testuser") - mock_pwd.assert_called_once_with(1000) - - @patch("moonset.users.os.getuid", return_value=1000) - @patch("moonset.users.pwd.getpwuid") - def test_empty_gecos_falls_back_to_username(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.display_name == "testuser" - - @patch("moonset.users.os.getuid", return_value=1000) - @patch("moonset.users.pwd.getpwuid") - def test_gecos_with_commas_uses_first_field(self, mock_pwd, mock_uid): - mock_pwd.return_value.pw_name = "testuser" - mock_pwd.return_value.pw_gecos = "Test User,,,Room 42" - mock_pwd.return_value.pw_dir = "/home/testuser" - mock_pwd.return_value.pw_uid = 1000 - user = get_current_user() - assert user.display_name == "Test User" - - -class TestGetAvatarPath: - """Tests for avatar path resolution.""" - - def test_returns_face_file_if_exists(self, tmp_path: Path): - face = tmp_path / ".face" - face.write_text("fake image") - path = get_avatar_path(tmp_path) - assert path == face - - def test_returns_accountsservice_icon_if_exists(self, tmp_path: Path): - username = "testuser" - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - icon = icons_dir / username - icon.write_text("fake image") - path = get_avatar_path( - tmp_path, username=username, accountsservice_dir=icons_dir - ) - assert path == icon - - def test_face_file_takes_priority_over_accountsservice(self, tmp_path: Path): - face = tmp_path / ".face" - face.write_text("fake image") - icons_dir = tmp_path / "icons" - icons_dir.mkdir() - icon = icons_dir / "testuser" - icon.write_text("fake image") - path = get_avatar_path( - tmp_path, username="testuser", accountsservice_dir=icons_dir - ) - assert path == face - - def test_returns_none_when_no_avatar(self, tmp_path: Path): - path = get_avatar_path(tmp_path) - assert path is None - - -class TestGetDefaultAvatarPath: - """Tests for default avatar fallback.""" - - def test_default_avatar_exists(self): - """The package default avatar must always be present.""" - path = get_default_avatar_path() - assert path.is_file() - - def test_default_avatar_is_svg(self): - """The default avatar should be an SVG file.""" - path = get_default_avatar_path() - assert path.suffix == ".svg" diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 356d80d..0000000 --- a/uv.lock +++ /dev/null @@ -1,45 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "moonset" -version = "0.2.0" -source = { editable = "." } -dependencies = [ - { name = "pygobject" }, -] - -[package.metadata] -requires-dist = [{ name = "pygobject", specifier = ">=3.46" }] - -[[package]] -name = "pycairo" -version = "1.29.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/92/1b904087e831806a449502786d47d3a468e5edb8f65755f6bd88e8038e53/pycairo-1.29.0-cp311-cp311-win32.whl", hash = "sha256:12757ebfb304b645861283c20585c9204c3430671fad925419cba04844d6dfed", size = 751342, upload-time = "2025-11-11T19:11:37.386Z" }, - { url = "https://files.pythonhosted.org/packages/db/09/a0ab6a246a7ede89e817d749a941df34f27a74bedf15551da51e86ae105e/pycairo-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:3391532db03f9601c1cee9ebfa15b7d1db183c6020f3e75c1348cee16825934f", size = 845036, upload-time = "2025-11-11T19:11:43.408Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/bf455454bac50baef553e7356d36b9d16e482403bf132cfb12960d2dc2e7/pycairo-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:b69be8bb65c46b680771dc6a1a422b1cdd0cffb17be548f223e8cbbb6205567c", size = 694644, upload-time = "2025-11-11T19:11:48.599Z" }, - { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, - { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, - { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, - { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, - { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, - { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, - { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, - { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, - { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, -] - -[[package]] -name = "pygobject" -version = "3.56.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycairo" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" }