Add wallpaper and default avatar support

Wallpaper with fallback hierarchy: config path > Moonarch system
default (/usr/share/moonarch/wallpaper.jpg) > package fallback.
Applied to both primary and secondary monitors.

Default avatar from Moongreet ecosystem with theme-colored SVG
rendering via PixbufLoader and proper clipping frame (Gtk.Box
with overflow hidden), matching the Moongreet avatar pattern.
This commit is contained in:
nevaforget 2026-03-26 13:01:25 +01:00
parent d0d390d0cb
commit dd9937b020
7 changed files with 167 additions and 17 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

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

View File

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

View File

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

View File

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

53
tests/test_wallpaper.py Normal file
View File

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