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:
parent
d0d390d0cb
commit
dd9937b020
@ -3,8 +3,12 @@
|
|||||||
|
|
||||||
import tomllib
|
import tomllib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg")
|
||||||
|
PACKAGE_WALLPAPER = Path(str(files("moonlock") / "data" / "wallpaper.jpg"))
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATHS = [
|
DEFAULT_CONFIG_PATHS = [
|
||||||
Path("/etc/moonlock/moonlock.toml"),
|
Path("/etc/moonlock/moonlock.toml"),
|
||||||
Path.home() / ".config" / "moonlock" / "moonlock.toml",
|
Path.home() / ".config" / "moonlock" / "moonlock.toml",
|
||||||
@ -37,3 +41,22 @@ def load_config(
|
|||||||
background_path=merged.get("background_path"),
|
background_path=merged.get("background_path"),
|
||||||
fingerprint_enabled=merged.get("fingerprint_enabled", True),
|
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
|
||||||
|
|||||||
BIN
src/moonlock/data/wallpaper.jpg
Normal file
BIN
src/moonlock/data/wallpaper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
@ -4,16 +4,19 @@
|
|||||||
import gi
|
import gi
|
||||||
gi.require_version("Gtk", "4.0")
|
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
|
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from moonlock.auth import authenticate
|
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.fingerprint import FingerprintListener
|
||||||
from moonlock.i18n import Strings, load_strings
|
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
|
from moonlock import power
|
||||||
|
|
||||||
FAILLOCK_MAX_ATTEMPTS = 3
|
FAILLOCK_MAX_ATTEMPTS = 3
|
||||||
|
AVATAR_SIZE = 128
|
||||||
|
|
||||||
|
|
||||||
class LockscreenWindow(Gtk.ApplicationWindow):
|
class LockscreenWindow(Gtk.ApplicationWindow):
|
||||||
@ -54,8 +57,10 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
overlay = Gtk.Overlay()
|
overlay = Gtk.Overlay()
|
||||||
self.set_child(overlay)
|
self.set_child(overlay)
|
||||||
|
|
||||||
# Background
|
# Background wallpaper
|
||||||
background = Gtk.Box()
|
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_hexpand(True)
|
||||||
background.set_vexpand(True)
|
background.set_vexpand(True)
|
||||||
overlay.set_child(background)
|
overlay.set_child(background)
|
||||||
@ -72,18 +77,22 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
self._login_box.add_css_class("login-box")
|
self._login_box.add_css_class("login-box")
|
||||||
main_box.append(self._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)
|
avatar_path = get_avatar_path(self._user.home, self._user.username)
|
||||||
if avatar_path:
|
if avatar_path:
|
||||||
avatar = Gtk.Picture.new_for_filename(str(avatar_path))
|
self._set_avatar_from_file(avatar_path)
|
||||||
avatar.set_content_fit(Gtk.ContentFit.COVER)
|
|
||||||
avatar.set_size_request(128, 128)
|
|
||||||
else:
|
else:
|
||||||
avatar = Gtk.Box()
|
self._set_default_avatar()
|
||||||
avatar.set_size_request(128, 128)
|
|
||||||
avatar.set_halign(Gtk.Align.CENTER)
|
|
||||||
avatar.add_css_class("avatar")
|
|
||||||
self._login_box.append(avatar)
|
|
||||||
|
|
||||||
# Username label
|
# Username label
|
||||||
username_label = Gtk.Label(label=self._user.display_name)
|
username_label = Gtk.Label(label=self._user.display_name)
|
||||||
@ -213,6 +222,44 @@ class LockscreenWindow(Gtk.ApplicationWindow):
|
|||||||
self._fp_label.remove_css_class("failed")
|
self._fp_label.remove_css_class("failed")
|
||||||
return GLib.SOURCE_REMOVE
|
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:
|
def _show_error(self, message: str) -> None:
|
||||||
"""Display an error message."""
|
"""Display an error message."""
|
||||||
self._error_label.set_text(message)
|
self._error_label.set_text(message)
|
||||||
|
|||||||
@ -16,7 +16,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
|
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
|
from moonlock.lockscreen import LockscreenWindow
|
||||||
|
|
||||||
# ext-session-lock-v1 via gtk4-layer-shell
|
# ext-session-lock-v1 via gtk4-layer-shell
|
||||||
@ -65,9 +65,16 @@ class MoonlockApp(Gtk.Application):
|
|||||||
config=self._config,
|
config=self._config,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Secondary monitors get a blank lockscreen
|
# Secondary monitors get the wallpaper without login UI
|
||||||
window = Gtk.ApplicationWindow(application=self)
|
window = Gtk.ApplicationWindow(application=self)
|
||||||
window.add_css_class("lockscreen")
|
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)
|
self._lock_instance.assign_window_to_monitor(window, monitor)
|
||||||
window.present()
|
window.present()
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
import os
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from importlib.resources import files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
|
DEFAULT_ACCOUNTSSERVICE_DIR = Path("/var/lib/AccountsService/icons")
|
||||||
@ -56,3 +57,8 @@ def get_avatar_path(
|
|||||||
return icon
|
return icon
|
||||||
|
|
||||||
return None
|
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"))
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
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:
|
class TestGetCurrentUser:
|
||||||
@ -79,3 +79,17 @@ class TestGetAvatarPath:
|
|||||||
def test_returns_none_when_no_avatar(self, tmp_path: Path):
|
def test_returns_none_when_no_avatar(self, tmp_path: Path):
|
||||||
path = get_avatar_path(tmp_path)
|
path = get_avatar_path(tmp_path)
|
||||||
assert path is None
|
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
53
tests/test_wallpaper.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user