diff --git a/src/moonlock/config.py b/src/moonlock/config.py index 73bda17..c20fef7 100644 --- a/src/moonlock/config.py +++ b/src/moonlock/config.py @@ -3,8 +3,12 @@ import tomllib from dataclasses import dataclass, field +from importlib.resources import files from pathlib import Path +MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg") +PACKAGE_WALLPAPER = Path(str(files("moonlock") / "data" / "wallpaper.jpg")) + DEFAULT_CONFIG_PATHS = [ Path("/etc/moonlock/moonlock.toml"), Path.home() / ".config" / "moonlock" / "moonlock.toml", @@ -37,3 +41,22 @@ def load_config( background_path=merged.get("background_path"), fingerprint_enabled=merged.get("fingerprint_enabled", True), ) + + +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/moonlock/data/wallpaper.jpg b/src/moonlock/data/wallpaper.jpg new file mode 100644 index 0000000..86371cd Binary files /dev/null and b/src/moonlock/data/wallpaper.jpg differ diff --git a/src/moonlock/lockscreen.py b/src/moonlock/lockscreen.py index 60957d8..e230a0a 100644 --- a/src/moonlock/lockscreen.py +++ b/src/moonlock/lockscreen.py @@ -4,16 +4,19 @@ import gi gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") -from gi.repository import Gtk, Gdk, GLib +from gi.repository import Gtk, Gdk, GdkPixbuf, GLib + +from pathlib import Path from moonlock.auth import authenticate -from moonlock.config import Config, load_config +from moonlock.config import Config, load_config, resolve_background_path from moonlock.fingerprint import FingerprintListener from moonlock.i18n import Strings, load_strings -from moonlock.users import get_current_user, get_avatar_path, User +from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User from moonlock import power FAILLOCK_MAX_ATTEMPTS = 3 +AVATAR_SIZE = 128 class LockscreenWindow(Gtk.ApplicationWindow): @@ -54,8 +57,10 @@ class LockscreenWindow(Gtk.ApplicationWindow): overlay = Gtk.Overlay() self.set_child(overlay) - # Background - background = Gtk.Box() + # Background wallpaper + wallpaper_path = resolve_background_path(self._config) + background = Gtk.Picture.new_for_filename(str(wallpaper_path)) + background.set_content_fit(Gtk.ContentFit.COVER) background.set_hexpand(True) background.set_vexpand(True) overlay.set_child(background) @@ -72,18 +77,22 @@ class LockscreenWindow(Gtk.ApplicationWindow): self._login_box.add_css_class("login-box") main_box.append(self._login_box) - # Avatar + # Avatar — wrapped in a clipping frame for round shape + 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) + self._login_box.append(avatar_frame) + avatar_path = get_avatar_path(self._user.home, self._user.username) if avatar_path: - avatar = Gtk.Picture.new_for_filename(str(avatar_path)) - avatar.set_content_fit(Gtk.ContentFit.COVER) - avatar.set_size_request(128, 128) + self._set_avatar_from_file(avatar_path) else: - avatar = Gtk.Box() - avatar.set_size_request(128, 128) - avatar.set_halign(Gtk.Align.CENTER) - avatar.add_css_class("avatar") - self._login_box.append(avatar) + self._set_default_avatar() # Username label username_label = Gtk.Label(label=self._user.display_name) @@ -213,6 +222,44 @@ class LockscreenWindow(Gtk.ApplicationWindow): self._fp_label.remove_css_class("failed") return GLib.SOURCE_REMOVE + def _get_foreground_color(self) -> str: + """Get the current GTK theme foreground color as a hex string.""" + rgba = self.get_color() + r = int(rgba.red * 255) + g = int(rgba.green * 255) + b = int(rgba.blue * 255) + return f"#{r:02x}{g:02x}{b:02x}" + + def _set_default_avatar(self) -> None: + """Load the default avatar SVG, tinted with the GTK foreground color.""" + try: + default_path = get_default_avatar_path() + svg_text = default_path.read_text() + fg_color = self._get_foreground_color() + 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 _set_avatar_from_file(self, path: Path) -> None: + """Load an image file and set it as the avatar, scaled to AVATAR_SIZE.""" + 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 _show_error(self, message: str) -> None: """Display an error message.""" self._error_label.set_text(message) diff --git a/src/moonlock/main.py b/src/moonlock/main.py index d437846..cfe5e49 100644 --- a/src/moonlock/main.py +++ b/src/moonlock/main.py @@ -16,7 +16,7 @@ gi.require_version("Gtk", "4.0") gi.require_version("Gdk", "4.0") from gi.repository import Gtk, Gdk -from moonlock.config import load_config +from moonlock.config import load_config, resolve_background_path from moonlock.lockscreen import LockscreenWindow # ext-session-lock-v1 via gtk4-layer-shell @@ -65,9 +65,16 @@ class MoonlockApp(Gtk.Application): config=self._config, ) else: - # Secondary monitors get a blank lockscreen + # Secondary monitors get the wallpaper without login UI window = Gtk.ApplicationWindow(application=self) window.add_css_class("lockscreen") + wallpaper = Gtk.Picture.new_for_filename( + str(resolve_background_path(self._config)) + ) + wallpaper.set_content_fit(Gtk.ContentFit.COVER) + wallpaper.set_hexpand(True) + wallpaper.set_vexpand(True) + window.set_child(wallpaper) self._lock_instance.assign_window_to_monitor(window, monitor) window.present() diff --git a/src/moonlock/users.py b/src/moonlock/users.py index 2bad10e..1d46a36 100644 --- a/src/moonlock/users.py +++ b/src/moonlock/users.py @@ -4,6 +4,7 @@ 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") @@ -56,3 +57,8 @@ def get_avatar_path( return icon return None + + +def get_default_avatar_path() -> Path: + """Return the path to the package default avatar SVG.""" + return Path(str(files("moonlock") / "data" / "default-avatar.svg")) diff --git a/tests/test_users.py b/tests/test_users.py index 3890638..3d86f0b 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -5,7 +5,7 @@ import os from pathlib import Path from unittest.mock import patch -from moonlock.users import get_current_user, get_avatar_path, User +from moonlock.users import get_current_user, get_avatar_path, get_default_avatar_path, User class TestGetCurrentUser: @@ -79,3 +79,17 @@ class TestGetAvatarPath: 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/tests/test_wallpaper.py b/tests/test_wallpaper.py new file mode 100644 index 0000000..37d46f5 --- /dev/null +++ b/tests/test_wallpaper.py @@ -0,0 +1,53 @@ +# ABOUTME: Tests for wallpaper path resolution. +# ABOUTME: Verifies fallback hierarchy: config > Moonarch system default > package fallback. + +from pathlib import Path +from unittest.mock import patch + +from moonlock.config import Config, resolve_background_path, MOONARCH_WALLPAPER, PACKAGE_WALLPAPER + + +class TestResolveBackgroundPath: + """Tests for the wallpaper fallback hierarchy.""" + + def test_config_path_used_when_file_exists(self, tmp_path: Path): + """Config background_path takes priority if the file exists.""" + wallpaper = tmp_path / "custom.jpg" + wallpaper.write_bytes(b"\xff\xd8") + config = Config(background_path=str(wallpaper)) + result = resolve_background_path(config) + assert result == wallpaper + + def test_config_path_skipped_when_file_missing(self, tmp_path: Path): + """Config path should be skipped if the file does not exist.""" + config = Config(background_path="/nonexistent/wallpaper.jpg") + with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): + result = resolve_background_path(config) + assert result == PACKAGE_WALLPAPER + + def test_moonarch_default_used_when_no_config(self, tmp_path: Path): + """Moonarch system wallpaper is used when config has no background_path.""" + moonarch_wp = tmp_path / "wallpaper.jpg" + moonarch_wp.write_bytes(b"\xff\xd8") + config = Config(background_path=None) + with patch("moonlock.config.MOONARCH_WALLPAPER", moonarch_wp): + result = resolve_background_path(config) + assert result == moonarch_wp + + def test_moonarch_default_skipped_when_missing(self, tmp_path: Path): + """Falls back to package wallpaper when Moonarch default is missing.""" + config = Config(background_path=None) + with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): + result = resolve_background_path(config) + assert result == PACKAGE_WALLPAPER + + def test_package_fallback_always_exists(self): + """The package fallback wallpaper must always be present.""" + assert PACKAGE_WALLPAPER.is_file() + + def test_full_fallback_chain(self, tmp_path: Path): + """With no config and no Moonarch default, package fallback is returned.""" + config = Config() + with patch("moonlock.config.MOONARCH_WALLPAPER", tmp_path / "nope.jpg"): + result = resolve_background_path(config) + assert result == PACKAGE_WALLPAPER