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"