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
|
- `sessions.py` — Wayland/X11 Sessions aus .desktop Files
|
||||||
- `power.py` — Reboot/Shutdown via loginctl
|
- `power.py` — Reboot/Shutdown via loginctl
|
||||||
- `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN)
|
- `i18n.py` — Locale-Erkennung (LANG / /etc/locale.conf) und String-Tabellen (DE/EN)
|
||||||
- `greeter.py` — GTK4 UI (Overlay-Layout), Faillock-Warnung nach 2 Fehlversuchen
|
- `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
|
- `main.py` — Entry Point, GTK App, Layer Shell Setup, Multi-Monitor-Orchestrierung
|
||||||
|
|
||||||
## Design Decisions
|
## Design Decisions
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
# ABOUTME: Configuration loading from moongreet.toml.
|
# 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
|
import tomllib
|
||||||
|
from contextlib import AbstractContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from importlib.resources import as_file, files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATHS = [
|
DEFAULT_CONFIG_PATHS = [
|
||||||
@ -50,3 +52,24 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
config.background = bg_path
|
config.background = bg_path
|
||||||
|
|
||||||
return config
|
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 stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
from importlib.resources import as_file, files
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
@ -18,7 +18,7 @@ gi.require_version("Gtk", "4.0")
|
|||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
|
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.i18n import load_strings, Strings
|
||||||
from moongreet.ipc import create_session, post_auth_response, start_session, cancel_session
|
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
|
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
|
MAX_USERNAME_LENGTH = 256
|
||||||
PACKAGE_DATA = files("moongreet") / "data"
|
PACKAGE_DATA = files("moongreet") / "data"
|
||||||
DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg"
|
DEFAULT_AVATAR_PATH = PACKAGE_DATA / "default-avatar.svg"
|
||||||
DEFAULT_WALLPAPER_PATH = PACKAGE_DATA / "wallpaper.jpg"
|
|
||||||
AVATAR_SIZE = 128
|
AVATAR_SIZE = 128
|
||||||
|
|
||||||
|
|
||||||
@ -49,10 +48,35 @@ def faillock_warning(attempt_count: int, strings: Strings | None = None) -> str
|
|||||||
return None
|
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):
|
class GreeterWindow(Gtk.ApplicationWindow):
|
||||||
"""The main greeter window with login UI."""
|
"""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)
|
super().__init__(**kwargs)
|
||||||
self.add_css_class("greeter")
|
self.add_css_class("greeter")
|
||||||
self.set_default_size(1920, 1080)
|
self.set_default_size(1920, 1080)
|
||||||
@ -67,14 +91,13 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
|||||||
self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None
|
self._default_avatar_pixbuf: GdkPixbuf.Pixbuf | None = None
|
||||||
self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {}
|
self._avatar_cache: dict[str, GdkPixbuf.Pixbuf] = {}
|
||||||
self._failed_attempts: dict[str, int] = {}
|
self._failed_attempts: dict[str, int] = {}
|
||||||
self._wallpaper_ctx = None
|
self._bg_path = bg_path
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._setup_keyboard_navigation()
|
self._setup_keyboard_navigation()
|
||||||
# Defer initial user selection until the window is realized,
|
# Defer initial user selection until the window is realized,
|
||||||
# so get_color() returns the actual theme foreground for SVG tinting
|
# so get_color() returns the actual theme foreground for SVG tinting
|
||||||
self.connect("realize", self._on_realize)
|
self.connect("realize", self._on_realize)
|
||||||
self.connect("unrealize", self._on_unrealize)
|
|
||||||
|
|
||||||
def _on_realize(self, widget: Gtk.Widget) -> None:
|
def _on_realize(self, widget: Gtk.Widget) -> None:
|
||||||
"""Called when the window is realized — select initial user.
|
"""Called when the window is realized — select initial user.
|
||||||
@ -85,35 +108,14 @@ class GreeterWindow(Gtk.ApplicationWindow):
|
|||||||
"""
|
"""
|
||||||
GLib.idle_add(self._select_initial_user)
|
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:
|
def _build_ui(self) -> None:
|
||||||
"""Build the complete greeter UI layout."""
|
"""Build the complete greeter UI layout."""
|
||||||
# Root overlay for layering
|
# Root overlay for layering
|
||||||
overlay = Gtk.Overlay()
|
overlay = Gtk.Overlay()
|
||||||
self.set_child(overlay)
|
self.set_child(overlay)
|
||||||
|
|
||||||
# Background wallpaper (blurred and darkened)
|
# Background wallpaper
|
||||||
bg_path = self._config.background
|
overlay.set_child(_build_wallpaper_widget(self._bg_path))
|
||||||
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)
|
|
||||||
|
|
||||||
# Main layout: 3 rows (top spacer, center login, bottom bar)
|
# Main layout: 3 rows (top spacer, center login, bottom bar)
|
||||||
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
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: 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
|
import sys
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
|
|
||||||
@ -9,7 +10,8 @@ gi.require_version("Gtk", "4.0")
|
|||||||
gi.require_version("Gdk", "4.0")
|
gi.require_version("Gdk", "4.0")
|
||||||
from gi.repository import Gtk, Gdk
|
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
|
# gtk4-layer-shell is optional for development/testing
|
||||||
try:
|
try:
|
||||||
@ -19,23 +21,65 @@ try:
|
|||||||
except (ValueError, ImportError):
|
except (ValueError, ImportError):
|
||||||
HAS_LAYER_SHELL = False
|
HAS_LAYER_SHELL = False
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class MoongreetApp(Gtk.Application):
|
class MoongreetApp(Gtk.Application):
|
||||||
"""GTK Application for the Moongreet greeter."""
|
"""GTK Application for the Moongreet greeter."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(application_id="dev.moonarch.moongreet")
|
super().__init__(application_id="dev.moonarch.moongreet")
|
||||||
|
self._wallpaper_ctx = None
|
||||||
|
self._secondary_windows: list[WallpaperWindow] = []
|
||||||
|
|
||||||
def do_activate(self) -> None:
|
def do_activate(self) -> None:
|
||||||
"""Create and present the greeter window."""
|
"""Create and present greeter windows on all monitors."""
|
||||||
self._register_icons()
|
self._register_icons()
|
||||||
self._load_css()
|
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:
|
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:
|
def _register_icons(self) -> None:
|
||||||
"""Register custom icons from the package data/icons directory."""
|
"""Register custom icons from the package data/icons directory."""
|
||||||
@ -54,13 +98,14 @@ class MoongreetApp(Gtk.Application):
|
|||||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _setup_layer_shell(self, window: Gtk.Window) -> None:
|
def _setup_layer_shell(self, window: Gtk.Window, keyboard: bool = False) -> None:
|
||||||
"""Configure gtk4-layer-shell for fullscreen greeter display."""
|
"""Configure gtk4-layer-shell for fullscreen display."""
|
||||||
Gtk4LayerShell.init_for_window(window)
|
Gtk4LayerShell.init_for_window(window)
|
||||||
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP)
|
Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.TOP)
|
||||||
Gtk4LayerShell.set_keyboard_mode(
|
if keyboard:
|
||||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
Gtk4LayerShell.set_keyboard_mode(
|
||||||
)
|
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||||
|
)
|
||||||
# Anchor to all edges for fullscreen
|
# Anchor to all edges for fullscreen
|
||||||
for edge in [
|
for edge in [
|
||||||
Gtk4LayerShell.Edge.TOP,
|
Gtk4LayerShell.Edge.TOP,
|
||||||
|
|||||||
@ -15,13 +15,11 @@ window.greeter {
|
|||||||
background-color: alpha(@theme_bg_color, 0.7);
|
background-color: alpha(@theme_bg_color, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Round avatar image */
|
/* Round avatar image — size is set via set_size_request() in code */
|
||||||
.avatar {
|
.avatar {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
min-width: 128px;
|
min-width: 128px;
|
||||||
min-height: 128px;
|
min-height: 128px;
|
||||||
max-width: 128px;
|
|
||||||
max-height: 128px;
|
|
||||||
background-color: @theme_selected_bg_color;
|
background-color: @theme_selected_bg_color;
|
||||||
border: 3px solid alpha(white, 0.3);
|
border: 3px solid alpha(white, 0.3);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from moongreet.config import load_config, Config
|
from moongreet.config import load_config, resolve_wallpaper_path, Config
|
||||||
|
|
||||||
|
|
||||||
class TestLoadConfig:
|
class TestLoadConfig:
|
||||||
@ -53,3 +53,38 @@ class TestLoadConfig:
|
|||||||
config = load_config(toml_file)
|
config = load_config(toml_file)
|
||||||
|
|
||||||
assert config.background == tmp_path / "wallpaper.jpg"
|
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