feat: Multi-Monitor-Support — Wallpaper auf Sekundärmonitoren

- 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
This commit is contained in:
nevaforget 2026-03-26 13:05:29 +01:00
parent 8f2540024d
commit 9738e71ecc
6 changed files with 150 additions and 47 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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);
}

View File

@ -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)