diff --git a/config/moongreet.toml b/config/moongreet.toml
index ac0f1ac..1fff555 100644
--- a/config/moongreet.toml
+++ b/config/moongreet.toml
@@ -2,7 +2,7 @@
# ABOUTME: Background image and other visual settings.
[appearance]
-# background = "/usr/share/backgrounds/moonarch.jpg"
+background = "../data/wallpaper.jpg"
[behavior]
# show_user_list = true
diff --git a/data/CREDITS.md b/data/CREDITS.md
new file mode 100644
index 0000000..dad1128
--- /dev/null
+++ b/data/CREDITS.md
@@ -0,0 +1,8 @@
+# Asset Credits
+
+## wallpaper.jpg
+
+- **Title**: Canyon Mountains on Night Sky
+- **Author**: eberhard grossgasteiger
+- **Source**: https://www.pexels.com/photo/canion-mountains-on-night-sky-2098428/
+- **License**: Pexels License (free for personal and commercial use)
diff --git a/data/default-avatar.svg b/data/default-avatar.svg
index f853292..e3da366 100644
--- a/data/default-avatar.svg
+++ b/data/default-avatar.svg
@@ -1,6 +1 @@
-
+
diff --git a/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg b/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg
new file mode 100644
index 0000000..9db9ddc
--- /dev/null
+++ b/data/icons/hicolor/scalable/apps/moongreet-default-avatar-symbolic.svg
@@ -0,0 +1 @@
+
diff --git a/data/wallpaper.jpg b/data/wallpaper.jpg
new file mode 100644
index 0000000..16962f8
Binary files /dev/null and b/data/wallpaper.jpg differ
diff --git a/src/moongreet/config.py b/src/moongreet/config.py
new file mode 100644
index 0000000..20e08c7
--- /dev/null
+++ b/src/moongreet/config.py
@@ -0,0 +1,50 @@
+# ABOUTME: Configuration loading from moongreet.toml.
+# ABOUTME: Parses appearance and behavior settings for the greeter.
+
+import tomllib
+from dataclasses import dataclass
+from pathlib import Path
+
+DEFAULT_CONFIG_PATHS = [
+ Path("/etc/moongreet/moongreet.toml"),
+ Path(__file__).parent.parent.parent / "config" / "moongreet.toml",
+]
+
+
+@dataclass
+class Config:
+ """Greeter configuration loaded from moongreet.toml."""
+
+ background: Path | None = None
+
+
+def load_config(config_path: Path | None = None) -> Config:
+ """Load configuration from a TOML file.
+
+ Relative paths in the config are resolved against the config file's directory.
+ """
+ if config_path is None:
+ for path in DEFAULT_CONFIG_PATHS:
+ if path.exists():
+ config_path = path
+ break
+ if config_path is None:
+ return Config()
+
+ if not config_path.exists():
+ return Config()
+
+ with open(config_path, "rb") as f:
+ data = tomllib.load(f)
+
+ config = Config()
+ appearance = data.get("appearance", {})
+
+ bg = appearance.get("background")
+ if bg:
+ bg_path = Path(bg)
+ if not bg_path.is_absolute():
+ bg_path = config_path.parent / bg_path
+ config.background = bg_path
+
+ return config
diff --git a/src/moongreet/greeter.py b/src/moongreet/greeter.py
index ca698c2..cf387d2 100644
--- a/src/moongreet/greeter.py
+++ b/src/moongreet/greeter.py
@@ -10,6 +10,7 @@ gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gtk, Gdk, GLib, Gio, GdkPixbuf
+from moongreet.config import load_config
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.sessions import Session, get_sessions
@@ -17,6 +18,7 @@ from moongreet.power import reboot, shutdown
LAST_USER_PATH = Path("/var/cache/moongreet/last-user")
DEFAULT_AVATAR_PATH = Path(__file__).parent.parent.parent / "data" / "default-avatar.svg"
+AVATAR_SIZE = 128
class GreeterWindow(Gtk.ApplicationWindow):
@@ -27,6 +29,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
self.add_css_class("greeter")
self.set_default_size(1920, 1080)
+ self._config = load_config()
self._users = get_users()
self._sessions = get_sessions()
self._selected_user: User | None = None
@@ -42,10 +45,17 @@ class GreeterWindow(Gtk.ApplicationWindow):
overlay = Gtk.Overlay()
self.set_child(overlay)
- # Background fills the whole window
- background = Gtk.Box()
- background.set_hexpand(True)
- background.set_vexpand(True)
+ # Background wallpaper (blurred and darkened)
+ if self._config.background and self._config.background.exists():
+ background = Gtk.Picture()
+ background.set_filename(str(self._config.background))
+ 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)
@@ -80,19 +90,16 @@ class GreeterWindow(Gtk.ApplicationWindow):
box.add_css_class("login-box")
box.set_halign(Gtk.Align.CENTER)
box.set_valign(Gtk.Align.CENTER)
- box.set_spacing(4)
+ box.set_spacing(12)
- # Avatar — wrapped in a fixed-size frame to constrain the Picture
+ # Avatar — wrapped in a clipping frame for round shape
avatar_frame = Gtk.Box()
- avatar_frame.set_size_request(96, 96)
+ 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.Picture()
- self._avatar_image.set_size_request(96, 96)
- self._avatar_image.set_content_fit(Gtk.ContentFit.COVER)
- self._avatar_image.set_hexpand(False)
- self._avatar_image.set_vexpand(False)
+ self._avatar_image = Gtk.Image()
+ self._avatar_image.set_pixel_size(AVATAR_SIZE)
avatar_frame.append(self._avatar_image)
box.append(avatar_frame)
@@ -104,7 +111,7 @@ class GreeterWindow(Gtk.ApplicationWindow):
# Session dropdown
self._session_dropdown = Gtk.DropDown()
self._session_dropdown.add_css_class("session-dropdown")
- self._session_dropdown.set_halign(Gtk.Align.CENTER)
+ self._session_dropdown.set_hexpand(True)
if self._sessions:
session_names = [s.name for s in self._sessions]
string_list = Gtk.StringList.new(session_names)
@@ -113,10 +120,10 @@ class GreeterWindow(Gtk.ApplicationWindow):
# Password entry
self._password_entry = Gtk.PasswordEntry()
+ self._password_entry.set_hexpand(True)
self._password_entry.set_property("placeholder-text", "Passwort")
self._password_entry.set_property("show-peek-icon", True)
self._password_entry.add_css_class("password-entry")
- self._password_entry.set_halign(Gtk.Align.CENTER)
self._password_entry.connect("activate", self._on_login_activate)
box.append(self._password_entry)
@@ -211,9 +218,11 @@ class GreeterWindow(Gtk.ApplicationWindow):
user.username, home_dir=user.home
)
if avatar_path and avatar_path.exists():
- self._avatar_image.set_filename(str(avatar_path))
+ self._set_avatar_from_file(avatar_path)
elif DEFAULT_AVATAR_PATH.exists():
- self._avatar_image.set_filename(str(DEFAULT_AVATAR_PATH))
+ self._set_default_avatar()
+ else:
+ self._avatar_image.set_from_icon_name("avatar-default-symbolic")
# Apply user's GTK theme if available
self._apply_user_theme(user)
@@ -235,6 +244,44 @@ class GreeterWindow(Gtk.ApplicationWindow):
else:
settings.reset_property("gtk-theme-name")
+ def _get_foreground_color(self) -> str:
+ """Get the current GTK theme foreground color as a hex string."""
+ color = self.get_style_context().lookup_color("theme_fg_color")
+ if color[0]:
+ rgba = color[1]
+ r = int(rgba.red * 255)
+ g = int(rgba.green * 255)
+ b = int(rgba.blue * 255)
+ return f"#{r:02x}{g:02x}{b:02x}"
+ return "#ffffff"
+
+ def _set_default_avatar(self) -> None:
+ """Load the default avatar SVG, tinted with the GTK foreground color."""
+ try:
+ svg_text = DEFAULT_AVATAR_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)
+ except (GLib.Error, OSError):
+ 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._avatar_image.set_from_icon_name("avatar-default-symbolic")
+
def _setup_keyboard_navigation(self) -> None:
"""Set up keyboard shortcuts."""
controller = Gtk.EventControllerKey()
diff --git a/src/moongreet/main.py b/src/moongreet/main.py
index 4e60497..dab02bf 100644
--- a/src/moongreet/main.py
+++ b/src/moongreet/main.py
@@ -28,6 +28,7 @@ class MoongreetApp(Gtk.Application):
def do_activate(self) -> None:
"""Create and present the greeter window."""
+ self._register_icons()
self._load_css()
window = GreeterWindow(application=self)
@@ -36,6 +37,12 @@ class MoongreetApp(Gtk.Application):
window.present()
+ def _register_icons(self) -> None:
+ """Register custom icons from the data/icons directory."""
+ icons_dir = os.path.join(os.path.dirname(__file__), "..", "..", "data", "icons")
+ icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
+ icon_theme.add_search_path(os.path.realpath(icons_dir))
+
def _load_css(self) -> None:
"""Load the CSS stylesheet for the greeter."""
css_provider = Gtk.CssProvider()
diff --git a/src/moongreet/style.css b/src/moongreet/style.css
index 89b1102..d3f3eab 100644
--- a/src/moongreet/style.css
+++ b/src/moongreet/style.css
@@ -12,15 +12,17 @@ window.greeter {
.login-box {
padding: 40px;
border-radius: 12px;
- background-color: alpha(@window_bg_color, 0.7);
+ background-color: alpha(@theme_bg_color, 0.7);
}
/* Round avatar image */
.avatar {
border-radius: 50%;
- min-width: 96px;
- min-height: 96px;
- background-color: #3a3a5c;
+ 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);
}
@@ -30,32 +32,23 @@ window.greeter {
font-weight: bold;
color: white;
margin-top: 12px;
- margin-bottom: 8px;
+ margin-bottom: 40px;
}
/* Session dropdown */
.session-dropdown {
- min-width: 200px;
- margin-bottom: 12px;
- border-radius: 6px;
+ min-width: 280px;
}
/* Password entry field */
.password-entry {
min-width: 280px;
- min-height: 40px;
- border-radius: 20px;
- padding-left: 16px;
- padding-right: 16px;
- font-size: 16px;
- margin-top: 8px;
}
/* Error message label */
.error-label {
color: #ff6b6b;
font-size: 14px;
- margin-top: 8px;
}
/* User list on the bottom left */
diff --git a/tests/test_config.py b/tests/test_config.py
new file mode 100644
index 0000000..d9b92ae
--- /dev/null
+++ b/tests/test_config.py
@@ -0,0 +1,47 @@
+# ABOUTME: Tests for configuration loading from moongreet.toml.
+# ABOUTME: Verifies parsing of appearance and behavior settings.
+
+from pathlib import Path
+
+import pytest
+
+from moongreet.config import load_config, Config
+
+
+class TestLoadConfig:
+ """Tests for loading moongreet.toml configuration."""
+
+ def test_loads_background_path(self, tmp_path: Path) -> None:
+ toml_file = tmp_path / "moongreet.toml"
+ toml_file.write_text(
+ "[appearance]\n"
+ 'background = "/usr/share/backgrounds/test.jpg"\n'
+ )
+
+ config = load_config(toml_file)
+
+ assert config.background == Path("/usr/share/backgrounds/test.jpg")
+
+ def test_returns_none_background_when_missing(self, tmp_path: Path) -> None:
+ toml_file = tmp_path / "moongreet.toml"
+ toml_file.write_text("[appearance]\n")
+
+ config = load_config(toml_file)
+
+ assert config.background is None
+
+ def test_returns_defaults_for_missing_file(self, tmp_path: Path) -> None:
+ config = load_config(tmp_path / "nonexistent.toml")
+
+ assert config.background is None
+
+ def test_resolves_relative_path_against_config_dir(self, tmp_path: Path) -> None:
+ toml_file = tmp_path / "moongreet.toml"
+ toml_file.write_text(
+ "[appearance]\n"
+ 'background = "wallpaper.jpg"\n'
+ )
+
+ config = load_config(toml_file)
+
+ assert config.background == tmp_path / "wallpaper.jpg"