From 9738e71ecc9e872e2f4f4b85334fb38efb531d83 Mon Sep 17 00:00:00 2001 From: nevaforget Date: Thu, 26 Mar 2026 13:05:29 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Multi-Monitor-Support=20=E2=80=94=20Wal?= =?UTF-8?q?lpaper=20auf=20Sekund=C3=A4rmonitoren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WallpaperWindow für Sekundärmonitore (nur Hintergrundbild) - GreeterWindow bekommt bg_path als Parameter - resolve_wallpaper_path() aus config.py extrahiert (wiederverwendbar) - main.py: Monitor-Enumeration, Layer-Shell pro Monitor - Keyboard-Exclusive nur auf dem primären Monitor - CSS: ungültige max-width/max-height Properties entfernt --- CLAUDE.md | 4 +-- src/moongreet/config.py | 25 ++++++++++++++- src/moongreet/greeter.py | 60 ++++++++++++++++++----------------- src/moongreet/main.py | 67 +++++++++++++++++++++++++++++++++------- src/moongreet/style.css | 4 +-- tests/test_config.py | 37 +++++++++++++++++++++- 6 files changed, 150 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1bf0f5a..bff9a29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,8 @@ uv run moongreet - `sessions.py` — Wayland/X11 Sessions aus .desktop Files - `power.py` — Reboot/Shutdown via loginctl - `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN) -- `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen -- `main.py` — Entry Point, GTK App, Layer Shell Setup +- `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen, WallpaperWindow für Sekundärmonitore +- `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor-Orchestrierung ## Design Decisions diff --git a/src/moongreet/config.py b/src/moongreet/config.py index f0c6b82..aa93afe 100644 --- a/src/moongreet/config.py +++ b/src/moongreet/config.py @@ -1,8 +1,10 @@ # ABOUTME: Configuration loading from moongreet.toml. -# ABOUTME: Parses appearance and behavior settings for the greeter. +# ABOUTME: Parses appearance and behavior settings with wallpaper path resolution. import tomllib +from contextlib import AbstractContextManager from dataclasses import dataclass +from importlib.resources import as_file, files from pathlib import Path DEFAULT_CONFIG_PATHS = [ @@ -50,3 +52,24 @@ def load_config(config_path: Path | None = None) -> Config: config.background = bg_path return config + + +_PACKAGE_DATA = files("moongreet") / "data" +_DEFAULT_WALLPAPER_PATH = _PACKAGE_DATA / "wallpaper.jpg" + + +def resolve_wallpaper_path( + config: Config, +) -> tuple[Path, AbstractContextManager | None]: + """Resolve the wallpaper path from config or fall back to the package default. + + Returns (path, context_manager). The context_manager is non-None when a + package resource was extracted to a temporary file — the caller must keep + it alive and call __exit__ when done. + """ + if config.background and config.background.exists(): + return config.background, None + + ctx = as_file(_DEFAULT_WALLPAPER_PATH) + path = ctx.__enter__() + return path, ctx diff --git a/src/moongreet/greeter.py b/src/moongreet/greeter.py index 64c724d..3c1d657 100644 --- a/src/moongreet/greeter.py +++ b/src/moongreet/greeter.py @@ -10,7 +10,7 @@ import socket import stat import subprocess import threading -from importlib.resources import as_file, files +from importlib.resources import files from pathlib import Path import gi @@ -18,7 +18,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk, GLib, GdkPixbuf -from moongreet.config import load_config +from moongreet.config import load_config, resolve_wallpaper_path from moongreet.i18n import load_strings, Strings from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session from moongreet.users import User, get_users, get_avatar_path, get_user_gtk_theme @@ -33,7 +33,6 @@ VALID_USERNAME = re.compile(r"^[a-zA-Z0-9_.-]+$") MAX_USERNAME_LENGTH = 256 PACKAGE_DATA = files("moongreet") / "data" DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg" -DEFAULT_WALLPAPER_PATH = PACKAGE_DATA / "wallpaper.jpg" AVATAR_SIZE = 128 @@ -49,10 +48,35 @@ def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str return None +def _build_wallpaper_widget(bg_path: Path | None) -> Gtk.Widget: + """Create a wallpaper widget that fills the available space.""" + if bg_path and bg_path.exists(): + background = Gtk.Picture() + background.set_filename(str(bg_path)) + background.set_content_fit(Gtk.ContentFit.COVER) + background.set_hexpand(True) + background.set_vexpand(True) + return background + background = Gtk.Box() + background.set_hexpand(True) + background.set_vexpand(True) + return background + + +class WallpaperWindow(Gtk.ApplicationWindow): + """A window that shows only the wallpaper — used for secondary monitors.""" + + def __init__(self, bg_path: Path | None = None, **kwargs) -> None: + super().__init__(**kwargs) + self.add_css_class("greeter") + self.set_default_size(1920, 1080) + self.set_child(_build_wallpaper_widget(bg_path)) + + class GreeterWindow(Gtk.ApplicationWindow): """The main greeter window with login UI.""" - def __init__(self, **kwargs) -> None: + def __init__(self, bg_path: Path | None = None, **kwargs) -> None: super().__init__(**kwargs) self.add_css_class("greeter") self.set_default_size(1920, 1080) @@ -67,14 +91,13 @@ class GreeterWindow(Gtk.ApplicationWindow): self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {} self._failed_attempts: dict[str, int] = {} - self._wallpaper_ctx = None + self._bg_path = bg_path self._build_ui() self._setup_keyboard_navigation() # Defer initial user selection until the window is realized, # so get_color() returns the actual theme foreground for SVG tinting self.connect("realize", self._on_realize) - self.connect("unrealize", self._on_unrealize) def _on_realize(self, widget: Gtk.Widget) -> None: """Called when the window is realized — select initial user. @@ -85,35 +108,14 @@ class GreeterWindow(Gtk.ApplicationWindow): """ GLib.idle_add(self._select_initial_user) - def _on_unrealize(self, widget: Gtk.Widget) -> None: - """Clean up resources when the window is unrealized.""" - if self._wallpaper_ctx is not None: - self._wallpaper_ctx.__exit__(None, None, None) - self._wallpaper_ctx = None - def _build_ui(self) -> None: """Build the complete greeter UI layout.""" # Root overlay for layering overlay = Gtk.Overlay() self.set_child(overlay) - # Background wallpaper (blurred and darkened) - bg_path = self._config.background - if not bg_path or not bg_path.exists(): - # Extract package wallpaper to a real filesystem path (works in ZIP wheels too) - self._wallpaper_ctx = as_file(DEFAULT_WALLPAPER_PATH) - bg_path = self._wallpaper_ctx.__enter__() - if bg_path.exists(): - background = Gtk.Picture() - background.set_filename(str(bg_path)) - background.set_content_fit(Gtk.ContentFit.COVER) - background.set_hexpand(True) - background.set_vexpand(True) - else: - background = Gtk.Box() - background.set_hexpand(True) - background.set_vexpand(True) - overlay.set_child(background) + # Background wallpaper + overlay.set_child(_build_wallpaper_widget(self._bg_path)) # Main layout: 3 rows (top spacer, center login, bottom bar) main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) diff --git a/src/moongreet/main.py b/src/moongreet/main.py index 893b7eb..ecca7fe 100644 --- a/src/moongreet/main.py +++ b/src/moongreet/main.py @@ -1,6 +1,7 @@ # ABOUTME: Entry point for Moongreet — sets up GTK Application and Layer Shell. -# ABOUTME: Handles CLI invocation and initializes the greeter window. +# ABOUTME: Handles multi-monitor setup: login UI on primary, wallpaper on secondary monitors. +import logging import sys from importlib.resources import files @@ -9,7 +10,8 @@ gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk -from moongreet.greeter import GreeterWindow +from moongreet.config import load_config, resolve_wallpaper_path +from moongreet.greeter import GreeterWindow, WallpaperWindow # gtk4-layer-shell is optional for development/testing try: @@ -19,23 +21,65 @@ try: except (ValueError, ImportError): HAS_LAYER_SHELL = False +logger = logging.getLogger(__name__) + class MoongreetApp(Gtk.Application): """GTK Application for the Moongreet greeter.""" def __init__(self) -> None: super().__init__(application_id="dev.moonarch.moongreet") + self._wallpaper_ctx = None + self._secondary_windows: list[WallpaperWindow] = [] def do_activate(self) -> None: - """Create and present the greeter window.""" + """Create and present greeter windows on all monitors.""" self._register_icons() self._load_css() - window = GreeterWindow(application=self) + # Resolve wallpaper once, share across all windows + config = load_config() + bg_path, self._wallpaper_ctx = resolve_wallpaper_path(config) + + display = Gdk.Display.get_default() + 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 greeter window (login UI) on primary monitor + greeter = GreeterWindow(bg_path=bg_path, application=self) if HAS_LAYER_SHELL: - self._setup_layer_shell(window) + self._setup_layer_shell(greeter, keyboard=True) + if primary_monitor is not None: + Gtk4LayerShell.set_monitor(greeter, primary_monitor) + greeter.present() - window.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 do_shutdown(self) -> None: + """Clean up wallpaper context manager on exit.""" + if self._wallpaper_ctx is not None: + self._wallpaper_ctx.__exit__(None, None, None) + self._wallpaper_ctx = None + Gtk.Application.do_shutdown(self) def _register_icons(self) -> None: """Register custom icons from the package data/icons directory.""" @@ -54,13 +98,14 @@ class MoongreetApp(Gtk.Application): Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) - def _setup_layer_shell(self, window: Gtk.Window) -> None: - """Configure gtk4-layer-shell for fullscreen greeter display.""" + def _setup_layer_shell(self, window: Gtk.Window, keyboard: bool = False) -> None: + """Configure gtk4-layer-shell for fullscreen display.""" Gtk4LayerShell.init_for_window(window) Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP) - Gtk4LayerShell.set_keyboard_mode( - window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE - ) + if keyboard: + Gtk4LayerShell.set_keyboard_mode( + window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE + ) # Anchor to all edges for fullscreen for edge in [ Gtk4LayerShell.Edge.TOP, diff --git a/src/moongreet/style.css b/src/moongreet/style.css index d3f3eab..6daca30 100644 --- a/src/moongreet/style.css +++ b/src/moongreet/style.css @@ -15,13 +15,11 @@ window.greeter { background-color: alpha(@theme_bg_color, 0.7); } -/* Round avatar image */ +/* Round avatar image — size is set via set_size_request() in code */ .avatar { border-radius: 50%; min-width: 128px; min-height: 128px; - max-width: 128px; - max-height: 128px; background-color: @theme_selected_bg_color; border: 3px solid alpha(white, 0.3); } diff --git a/tests/test_config.py b/tests/test_config.py index 53c673e..88de72d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from moongreet.config import load_config, Config +from moongreet.config import load_config, resolve_wallpaper_path, Config class TestLoadConfig: @@ -53,3 +53,38 @@ class TestLoadConfig: config = load_config(toml_file) assert config.background == tmp_path / "wallpaper.jpg" + + +class TestResolveWallpaperPath: + """Tests for resolving the wallpaper path from config or package default.""" + + def test_uses_configured_path_when_exists(self, tmp_path: Path) -> None: + wallpaper = tmp_path / "custom.jpg" + wallpaper.write_bytes(b"fake-image") + config = Config(background=wallpaper) + + path, ctx = resolve_wallpaper_path(config) + + assert path == wallpaper + assert ctx is None + + def test_falls_back_to_package_default(self) -> None: + config = Config(background=None) + + path, ctx = resolve_wallpaper_path(config) + + assert path is not None + assert path.exists() + assert ctx is not None + # Clean up context manager + ctx.__exit__(None, None, None) + + def test_falls_back_when_configured_path_missing(self, tmp_path: Path) -> None: + config = Config(background=tmp_path / "nonexistent.jpg") + + path, ctx = resolve_wallpaper_path(config) + + assert path is not None + assert path.exists() + assert ctx is not None + ctx.__exit__(None, None, None)