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:
parent
8f2540024d
commit
9738e71ecc
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user