chore: remove Python implementation and build config
Rust rewrite provides full feature parity. Python sources, tests, pyproject.toml, and uv.lock are no longer needed.
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
# ABOUTME: Moonset package — a Wayland session power menu with GTK4.
|
||||
# ABOUTME: Part of the Moonarch ecosystem.
|
||||
@@ -1,60 +0,0 @@
|
||||
# ABOUTME: Configuration loading for the session power menu.
|
||||
# ABOUTME: Reads moonset.toml for wallpaper settings with fallback hierarchy.
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from importlib.resources import files
|
||||
from pathlib import Path
|
||||
|
||||
MOONARCH_WALLPAPER = Path("/usr/share/moonarch/wallpaper.jpg")
|
||||
PACKAGE_WALLPAPER = Path(str(files("moonset") / "data" / "wallpaper.jpg"))
|
||||
|
||||
DEFAULT_CONFIG_PATHS = [
|
||||
Path("/etc/moonset/moonset.toml"),
|
||||
Path.home() / ".config" / "moonset" / "moonset.toml",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Power menu configuration."""
|
||||
|
||||
background_path: str | None = None
|
||||
|
||||
|
||||
def load_config(
|
||||
config_paths: list[Path] | None = None,
|
||||
) -> Config:
|
||||
"""Load config from TOML file. Later paths override earlier ones."""
|
||||
if config_paths is None:
|
||||
config_paths = DEFAULT_CONFIG_PATHS
|
||||
|
||||
merged: dict = {}
|
||||
for path in config_paths:
|
||||
if path.exists():
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
merged.update(data)
|
||||
|
||||
return Config(
|
||||
background_path=merged.get("background_path"),
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 512 512"><path fill="#PLACEHOLDER" d="M256 23c-16.076 0-32.375 3.73-48.178 10.24c-2.126 6.525-3.877 14.76-4.877 23.754c-1.31 11.79-1.73 24.706-1.87 36.819c33.864-3.704 75.986-3.704 109.85 0c-.14-12.113-.56-25.03-1.87-36.82c-1-8.992-2.75-17.228-4.877-23.753C288.375 26.73 272.076 23 256 23m100.564 19.332c9.315 7.054 18.107 14.878 26.282 23.234c1.53-6.65 4.69-12.696 9.03-17.695zm-170.03 1.49c-34.675 20.22-65.047 52.714-82.552 86.334c-33.08 63.536-39.69 156.956-.53 214.8C132.786 388.278 200.276 405 256 405s123.215-16.72 152.547-60.045c39.162-57.843 32.55-151.263-.53-214.8c-17.504-33.62-47.876-66.112-82.55-86.333c.578 3.65 1.057 7.388 1.478 11.184c1.522 13.694 1.912 28.197 2.014 41.267C347.664 99.427 362 104 368 110c32 32 75.537 134.695 16 224c-37.654 56.48-218.346 56.48-256 0c-59.537-89.305-16-192 16-224c6-6 20.335-10.573 39.04-13.727c.103-13.07.493-27.573 2.015-41.267c.42-3.796.9-7.534 1.478-11.184zM64 48c-8.837 0-16 7.163-16 16a16 16 0 0 0 7 13.227V145.5L73 132V77.21A16 16 0 0 0 80 64c0-8.837-7.163-16-16-16m358.81 3.68c-12.81 0-23 10.19-23 23s10.19 23 23 23s23-10.19 23-23s-10.19-23-23-23m25.272 55.205c-6.98 5.497-15.758 8.795-25.27 8.795c-.745 0-1.48-.027-2.214-.067a217 217 0 0 1 2.38 4.37l29.852 22.39zm-238.822 2.5c-17.257.09-37.256 3.757-53.233 16.12c-26.634 20.608-43.034 114.763-33.49 146.763c16.584-61.767 31.993-124.02 107.92-161.274a133.5 133.5 0 0 0-21.197-1.61zm-135.055 44.21L40.15 179.138l-14.48 72.408l38.18 45.814c-10.947-46.523-5.776-98.723 10.355-143.764zm363.59 0c16.13 45.042 21.302 97.242 10.355 143.764l38.18-45.815l-14.48-72.408zM106.645 375.93c-3.583 1.17-7.252 3.406-10.282 6.435c-4.136 4.136-6.68 9.43-7.164 14.104c.21.364.603 1.157 1.73 2.162c2.453 2.188 6.693 5.17 12.127 8.358c10.867 6.38 26.55 13.757 44.205 20.623c21.177 8.237 45.35 15.704 67.738 20.38v-27.61c-39.47-5.12-79.897-18.325-108.355-44.452zm298.71 0C376.897 402.055 336.47 415.26 297 420.38v27.61c22.387-4.676 46.56-12.143 67.738-20.38c17.655-6.865 33.338-14.243 44.205-20.622c5.434-3.19 9.674-6.17 12.127-8.36c1.127-1.004 1.52-1.797 1.73-2.16c-.482-4.675-3.027-9.97-7.163-14.105c-3.03-3.03-6.7-5.264-10.282-6.435zM77.322 410.602L18 450.15V494h37v-18h18v18h366v-18h18v18h37v-43.85l-59.322-39.548c-.537.488-1.08.97-1.623 1.457c-3.922 3.497-8.932 6.89-14.998 10.452c-12.133 7.12-28.45 14.743-46.795 21.877C334.572 458.656 290.25 471 256 471s-78.572-12.343-115.262-26.61c-18.345-7.135-34.662-14.757-46.795-21.878c-6.066-3.56-11.076-6.955-14.998-10.453c-.543-.487-1.086-.97-1.623-1.458zM233 422.184v28.992c8.236 1.162 16.012 1.824 23 1.824s14.764-.662 23-1.824v-28.992a325 325 0 0 1-46 0"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB |
@@ -1,108 +0,0 @@
|
||||
# ABOUTME: Locale detection and string lookup for the power menu UI.
|
||||
# ABOUTME: Reads system locale (LANG or /etc/locale.conf) and provides DE or EN strings.
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_LOCALE_CONF = Path("/etc/locale.conf")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Strings:
|
||||
"""All user-visible strings for the power menu UI."""
|
||||
|
||||
# Button labels
|
||||
lock_label: str
|
||||
logout_label: str
|
||||
hibernate_label: str
|
||||
reboot_label: str
|
||||
shutdown_label: str
|
||||
|
||||
# Confirmation prompts
|
||||
logout_confirm: str
|
||||
hibernate_confirm: str
|
||||
reboot_confirm: str
|
||||
shutdown_confirm: str
|
||||
|
||||
# Confirmation buttons
|
||||
confirm_yes: str
|
||||
confirm_no: str
|
||||
|
||||
# Error messages
|
||||
lock_failed: str
|
||||
logout_failed: str
|
||||
hibernate_failed: str
|
||||
reboot_failed: str
|
||||
shutdown_failed: str
|
||||
|
||||
|
||||
_STRINGS_DE = Strings(
|
||||
lock_label="Sperren",
|
||||
logout_label="Abmelden",
|
||||
hibernate_label="Ruhezustand",
|
||||
reboot_label="Neustart",
|
||||
shutdown_label="Herunterfahren",
|
||||
logout_confirm="Wirklich abmelden?",
|
||||
hibernate_confirm="Wirklich in den Ruhezustand?",
|
||||
reboot_confirm="Wirklich neu starten?",
|
||||
shutdown_confirm="Wirklich herunterfahren?",
|
||||
confirm_yes="Ja",
|
||||
confirm_no="Abbrechen",
|
||||
lock_failed="Sperren fehlgeschlagen",
|
||||
logout_failed="Abmelden fehlgeschlagen",
|
||||
hibernate_failed="Ruhezustand fehlgeschlagen",
|
||||
reboot_failed="Neustart fehlgeschlagen",
|
||||
shutdown_failed="Herunterfahren fehlgeschlagen",
|
||||
)
|
||||
|
||||
_STRINGS_EN = Strings(
|
||||
lock_label="Lock",
|
||||
logout_label="Log out",
|
||||
hibernate_label="Hibernate",
|
||||
reboot_label="Reboot",
|
||||
shutdown_label="Shut down",
|
||||
logout_confirm="Really log out?",
|
||||
hibernate_confirm="Really hibernate?",
|
||||
reboot_confirm="Really reboot?",
|
||||
shutdown_confirm="Really shut down?",
|
||||
confirm_yes="Yes",
|
||||
confirm_no="Cancel",
|
||||
lock_failed="Lock failed",
|
||||
logout_failed="Log out failed",
|
||||
hibernate_failed="Hibernate failed",
|
||||
reboot_failed="Reboot failed",
|
||||
shutdown_failed="Shutdown failed",
|
||||
)
|
||||
|
||||
_LOCALE_MAP: dict[str, Strings] = {
|
||||
"de": _STRINGS_DE,
|
||||
"en": _STRINGS_EN,
|
||||
}
|
||||
|
||||
|
||||
def detect_locale(locale_conf_path: Path = DEFAULT_LOCALE_CONF) -> str:
|
||||
"""Determine the system language from LANG env var or /etc/locale.conf."""
|
||||
lang = os.environ.get("LANG")
|
||||
|
||||
if not lang and locale_conf_path.exists():
|
||||
for line in locale_conf_path.read_text().splitlines():
|
||||
if line.startswith("LANG="):
|
||||
lang = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
if not lang or lang in ("C", "POSIX"):
|
||||
return "en"
|
||||
|
||||
# Extract language prefix: "de_DE.UTF-8" → "de"
|
||||
lang = lang.split("_")[0].split(".")[0].lower()
|
||||
if not lang.isalpha():
|
||||
return "en"
|
||||
return lang
|
||||
|
||||
|
||||
def load_strings(locale: str | None = None) -> Strings:
|
||||
"""Return the string table for the given locale, defaulting to English."""
|
||||
if locale is None:
|
||||
locale = detect_locale()
|
||||
return _LOCALE_MAP.get(locale, _STRINGS_EN)
|
||||
@@ -1,138 +0,0 @@
|
||||
# ABOUTME: Entry point for Moonset — sets up GTK Application and Layer Shell.
|
||||
# ABOUTME: Handles multi-monitor setup: power menu on primary, wallpaper on secondary monitors.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from importlib.resources import files
|
||||
|
||||
# gtk4-layer-shell must be loaded before libwayland-client.
|
||||
# Only allow our own library in LD_PRELOAD — discard anything inherited from the environment.
|
||||
_LAYER_SHELL_LIB = "/usr/lib/libgtk4-layer-shell.so"
|
||||
_existing_preload = os.environ.get("LD_PRELOAD", "")
|
||||
_is_testing = "pytest" in sys.modules or "unittest" in sys.modules
|
||||
if (
|
||||
not _is_testing
|
||||
and _LAYER_SHELL_LIB not in _existing_preload
|
||||
and os.path.exists(_LAYER_SHELL_LIB)
|
||||
):
|
||||
os.environ["LD_PRELOAD"] = _LAYER_SHELL_LIB
|
||||
os.execvp(sys.executable, [sys.executable, "-m", "moonset.main"] + sys.argv[1:])
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gtk, Gdk
|
||||
|
||||
from moonset.config import load_config, resolve_background_path
|
||||
from moonset.panel import PanelWindow, WallpaperWindow
|
||||
|
||||
# gtk4-layer-shell is optional for development/testing
|
||||
try:
|
||||
gi.require_version("Gtk4LayerShell", "1.0")
|
||||
from gi.repository import Gtk4LayerShell
|
||||
HAS_LAYER_SHELL = True
|
||||
except (ValueError, ImportError):
|
||||
HAS_LAYER_SHELL = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""Configure logging to stderr."""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
)
|
||||
|
||||
stderr_handler = logging.StreamHandler(sys.stderr)
|
||||
stderr_handler.setLevel(logging.INFO)
|
||||
stderr_handler.setFormatter(formatter)
|
||||
root.addHandler(stderr_handler)
|
||||
|
||||
|
||||
class MoonsetApp(Gtk.Application):
|
||||
"""GTK Application for the Moonset power menu."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id="dev.moonarch.moonset")
|
||||
|
||||
def do_activate(self) -> None:
|
||||
"""Create and present power menu windows on all monitors."""
|
||||
display = Gdk.Display.get_default()
|
||||
if display is None:
|
||||
logger.error("No display available — cannot start power menu UI")
|
||||
return
|
||||
|
||||
self._load_css(display)
|
||||
|
||||
# Resolve wallpaper once, share across all windows
|
||||
config = load_config()
|
||||
bg_path = resolve_background_path(config)
|
||||
|
||||
# Panel on focused output (no set_monitor → compositor picks focused)
|
||||
panel = PanelWindow(bg_path=bg_path, application=self)
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(panel, keyboard=True)
|
||||
panel.present()
|
||||
|
||||
# Wallpaper on all other monitors
|
||||
monitors = display.get_monitors()
|
||||
for i in range(monitors.get_n_items()):
|
||||
monitor = monitors.get_item(i)
|
||||
wallpaper = WallpaperWindow(bg_path=bg_path, application=self)
|
||||
if HAS_LAYER_SHELL:
|
||||
self._setup_layer_shell(
|
||||
wallpaper, keyboard=False,
|
||||
layer=Gtk4LayerShell.Layer.TOP,
|
||||
)
|
||||
Gtk4LayerShell.set_monitor(wallpaper, monitor)
|
||||
wallpaper.present()
|
||||
|
||||
def _load_css(self, display: Gdk.Display) -> None:
|
||||
"""Load the CSS stylesheet for the power menu."""
|
||||
css_provider = Gtk.CssProvider()
|
||||
css_path = files("moonset") / "style.css"
|
||||
css_provider.load_from_path(str(css_path))
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
display,
|
||||
css_provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
def _setup_layer_shell(
|
||||
self, window: Gtk.Window, keyboard: bool = False,
|
||||
layer: int | None = None,
|
||||
) -> None:
|
||||
"""Configure gtk4-layer-shell for fullscreen display."""
|
||||
Gtk4LayerShell.init_for_window(window)
|
||||
Gtk4LayerShell.set_layer(
|
||||
window, layer if layer is not None else Gtk4LayerShell.Layer.OVERLAY
|
||||
)
|
||||
Gtk4LayerShell.set_exclusive_zone(window, -1)
|
||||
if keyboard:
|
||||
Gtk4LayerShell.set_keyboard_mode(
|
||||
window, Gtk4LayerShell.KeyboardMode.EXCLUSIVE
|
||||
)
|
||||
# Anchor to all edges for fullscreen
|
||||
for edge in [
|
||||
Gtk4LayerShell.Edge.TOP,
|
||||
Gtk4LayerShell.Edge.BOTTOM,
|
||||
Gtk4LayerShell.Edge.LEFT,
|
||||
Gtk4LayerShell.Edge.RIGHT,
|
||||
]:
|
||||
Gtk4LayerShell.set_anchor(window, edge, True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the Moonset application."""
|
||||
_setup_logging()
|
||||
logger.info("Moonset starting")
|
||||
app = MoonsetApp()
|
||||
app.run(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,395 +0,0 @@
|
||||
# ABOUTME: UI module for the power menu — action buttons, confirmation flow, wallpaper windows.
|
||||
# ABOUTME: Defines PanelWindow (primary monitor) and WallpaperWindow (secondary monitors).
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Gdk", "4.0")
|
||||
from gi.repository import Gtk, Gdk, GdkPixbuf, GLib
|
||||
|
||||
from moonset.i18n import Strings, load_strings
|
||||
from moonset.users import get_current_user, get_avatar_path, get_default_avatar_path
|
||||
from moonset import power
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AVATAR_SIZE = 128
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionDef:
|
||||
"""Definition for a single power action button."""
|
||||
|
||||
name: str
|
||||
icon_name: str
|
||||
needs_confirm: bool
|
||||
action_fn: Callable[[], None]
|
||||
label_attr: str
|
||||
error_attr: str
|
||||
confirm_attr: str | None
|
||||
|
||||
def get_label(self, strings: Strings) -> str:
|
||||
return getattr(strings, self.label_attr)
|
||||
|
||||
def get_error_message(self, strings: Strings) -> str:
|
||||
return getattr(strings, self.error_attr)
|
||||
|
||||
def get_confirm_prompt(self, strings: Strings) -> str | None:
|
||||
if self.confirm_attr is None:
|
||||
return None
|
||||
return getattr(strings, self.confirm_attr)
|
||||
|
||||
|
||||
ACTION_DEFINITIONS: list[ActionDef] = [
|
||||
ActionDef(
|
||||
name="lock",
|
||||
icon_name="system-lock-screen-symbolic",
|
||||
needs_confirm=False,
|
||||
action_fn=power.lock,
|
||||
label_attr="lock_label",
|
||||
error_attr="lock_failed",
|
||||
confirm_attr=None,
|
||||
),
|
||||
ActionDef(
|
||||
name="logout",
|
||||
icon_name="system-log-out-symbolic",
|
||||
needs_confirm=True,
|
||||
action_fn=power.logout,
|
||||
label_attr="logout_label",
|
||||
error_attr="logout_failed",
|
||||
confirm_attr="logout_confirm",
|
||||
),
|
||||
ActionDef(
|
||||
name="hibernate",
|
||||
icon_name="system-hibernate-symbolic",
|
||||
needs_confirm=True,
|
||||
action_fn=power.hibernate,
|
||||
label_attr="hibernate_label",
|
||||
error_attr="hibernate_failed",
|
||||
confirm_attr="hibernate_confirm",
|
||||
),
|
||||
ActionDef(
|
||||
name="reboot",
|
||||
icon_name="system-reboot-symbolic",
|
||||
needs_confirm=True,
|
||||
action_fn=power.reboot,
|
||||
label_attr="reboot_label",
|
||||
error_attr="reboot_failed",
|
||||
confirm_attr="reboot_confirm",
|
||||
),
|
||||
ActionDef(
|
||||
name="shutdown",
|
||||
icon_name="system-shutdown-symbolic",
|
||||
needs_confirm=True,
|
||||
action_fn=power.shutdown,
|
||||
label_attr="shutdown_label",
|
||||
error_attr="shutdown_failed",
|
||||
confirm_attr="shutdown_confirm",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class WallpaperWindow(Gtk.ApplicationWindow):
|
||||
"""Fullscreen wallpaper-only window for secondary monitors."""
|
||||
|
||||
def __init__(self, bg_path: Path, application: Gtk.Application) -> None:
|
||||
super().__init__(application=application)
|
||||
self.add_css_class("wallpaper")
|
||||
|
||||
background = Gtk.Picture.new_for_filename(str(bg_path))
|
||||
background.set_content_fit(Gtk.ContentFit.COVER)
|
||||
background.set_hexpand(True)
|
||||
background.set_vexpand(True)
|
||||
self.set_child(background)
|
||||
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _on_map(self, widget: Gtk.Widget) -> None:
|
||||
"""Trigger fade-in once the window is visible."""
|
||||
GLib.idle_add(lambda: self.add_css_class("visible") or GLib.SOURCE_REMOVE)
|
||||
|
||||
|
||||
class PanelWindow(Gtk.ApplicationWindow):
|
||||
"""Fullscreen power menu window for the primary monitor."""
|
||||
|
||||
def __init__(self, bg_path: Path, application: Gtk.Application) -> None:
|
||||
super().__init__(application=application)
|
||||
self.add_css_class("panel")
|
||||
|
||||
self._strings = load_strings()
|
||||
self._app = application
|
||||
self._user = get_current_user()
|
||||
self._confirm_box: Gtk.Box | None = None
|
||||
|
||||
self._build_ui(bg_path)
|
||||
self._setup_keyboard()
|
||||
|
||||
# Focus the first action button once the window is mapped
|
||||
self.connect("map", self._on_map)
|
||||
|
||||
def _build_ui(self, bg_path: Path) -> None:
|
||||
"""Build the panel layout with wallpaper background and action buttons."""
|
||||
# Main overlay for background + centered content
|
||||
overlay = Gtk.Overlay()
|
||||
self.set_child(overlay)
|
||||
|
||||
# Background wallpaper
|
||||
background = Gtk.Picture.new_for_filename(str(bg_path))
|
||||
background.set_content_fit(Gtk.ContentFit.COVER)
|
||||
background.set_hexpand(True)
|
||||
background.set_vexpand(True)
|
||||
overlay.set_child(background)
|
||||
|
||||
# Click on background dismisses the menu
|
||||
click_controller = Gtk.GestureClick()
|
||||
click_controller.connect("released", self._on_background_click)
|
||||
background.add_controller(click_controller)
|
||||
|
||||
# Centered content box
|
||||
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
content_box.set_halign(Gtk.Align.CENTER)
|
||||
content_box.set_valign(Gtk.Align.CENTER)
|
||||
overlay.add_overlay(content_box)
|
||||
|
||||
# Avatar
|
||||
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)
|
||||
content_box.append(avatar_frame)
|
||||
|
||||
avatar_path = get_avatar_path(self._user.home, self._user.username)
|
||||
if avatar_path:
|
||||
self._set_avatar_from_file(avatar_path)
|
||||
else:
|
||||
self._set_default_avatar()
|
||||
|
||||
# Username label
|
||||
username_label = Gtk.Label(label=self._user.display_name)
|
||||
username_label.add_css_class("username-label")
|
||||
content_box.append(username_label)
|
||||
|
||||
# Action buttons row
|
||||
self._button_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=24
|
||||
)
|
||||
self._button_box.set_halign(Gtk.Align.CENTER)
|
||||
content_box.append(self._button_box)
|
||||
|
||||
for action_def in ACTION_DEFINITIONS:
|
||||
button = self._create_action_button(action_def)
|
||||
self._button_box.append(button)
|
||||
|
||||
# Confirmation area (below buttons)
|
||||
self._confirm_area = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self._confirm_area.set_halign(Gtk.Align.CENTER)
|
||||
self._confirm_area.set_margin_top(24)
|
||||
content_box.append(self._confirm_area)
|
||||
|
||||
# Error label
|
||||
self._error_label = Gtk.Label()
|
||||
self._error_label.add_css_class("error-label")
|
||||
self._error_label.set_visible(False)
|
||||
self._error_label.set_margin_top(16)
|
||||
content_box.append(self._error_label)
|
||||
|
||||
def _create_action_button(self, action_def: ActionDef) -> Gtk.Button:
|
||||
"""Create a single action button with icon and label."""
|
||||
button_content = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=4
|
||||
)
|
||||
button_content.set_halign(Gtk.Align.CENTER)
|
||||
button_content.set_valign(Gtk.Align.CENTER)
|
||||
|
||||
# Look up the 22px icon variant (matches moonlock), render at 64px
|
||||
display = Gdk.Display.get_default()
|
||||
theme = Gtk.IconTheme.get_for_display(display)
|
||||
icon_paintable = theme.lookup_icon(
|
||||
action_def.icon_name, None, 22, 1,
|
||||
Gtk.TextDirection.NONE, Gtk.IconLookupFlags.FORCE_SYMBOLIC,
|
||||
)
|
||||
icon_file = icon_paintable.get_file()
|
||||
icon = Gtk.Image()
|
||||
if icon_file:
|
||||
# Load the SVG at 64px via GdkPixbuf
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
icon_file.get_path(), 64, 64, True
|
||||
)
|
||||
icon.set_from_pixbuf(pixbuf)
|
||||
else:
|
||||
icon.set_from_icon_name(action_def.icon_name)
|
||||
icon.set_pixel_size(64)
|
||||
icon.add_css_class("action-icon")
|
||||
button_content.append(icon)
|
||||
|
||||
label = Gtk.Label(label=action_def.get_label(self._strings))
|
||||
label.add_css_class("action-label")
|
||||
button_content.append(label)
|
||||
|
||||
button = Gtk.Button()
|
||||
button.set_child(button_content)
|
||||
button.add_css_class("action-button")
|
||||
button.connect("clicked", lambda _, ad=action_def: self._on_action_clicked(ad))
|
||||
return button
|
||||
|
||||
def _setup_keyboard(self) -> None:
|
||||
"""Set up keyboard event handling — Escape dismisses."""
|
||||
controller = Gtk.EventControllerKey()
|
||||
controller.connect("key-pressed", self._on_key_pressed)
|
||||
self.add_controller(controller)
|
||||
|
||||
def _on_map(self, widget: Gtk.Widget) -> None:
|
||||
"""Trigger fade-in and grab focus once the window is visible."""
|
||||
GLib.idle_add(self._fade_in)
|
||||
|
||||
def _fade_in(self) -> bool:
|
||||
"""Add visible class to trigger CSS opacity transition, then grab focus."""
|
||||
self.add_css_class("visible")
|
||||
GLib.idle_add(self._grab_initial_focus)
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def _grab_initial_focus(self) -> bool:
|
||||
"""Set focus on the first action button."""
|
||||
first_button = self._button_box.get_first_child()
|
||||
if first_button is not None:
|
||||
first_button.grab_focus()
|
||||
return GLib.SOURCE_REMOVE
|
||||
|
||||
def _on_key_pressed(
|
||||
self,
|
||||
controller: Gtk.EventControllerKey,
|
||||
keyval: int,
|
||||
keycode: int,
|
||||
state: Gdk.ModifierType,
|
||||
) -> bool:
|
||||
"""Handle key presses — Escape dismisses the power menu."""
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
self._app.quit()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _on_background_click(
|
||||
self,
|
||||
gesture: Gtk.GestureClick,
|
||||
n_press: int,
|
||||
x: float,
|
||||
y: float,
|
||||
) -> None:
|
||||
"""Dismiss the power menu when background is clicked."""
|
||||
self._app.quit()
|
||||
|
||||
def _on_action_clicked(self, action_def: ActionDef) -> None:
|
||||
"""Handle an action button click."""
|
||||
self._dismiss_confirm()
|
||||
self._error_label.set_visible(False)
|
||||
|
||||
if not action_def.needs_confirm:
|
||||
self._execute_action(action_def)
|
||||
return
|
||||
|
||||
self._show_confirm(action_def)
|
||||
|
||||
def _show_confirm(self, action_def: ActionDef) -> None:
|
||||
"""Show inline confirmation below the action buttons."""
|
||||
self._confirm_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=8
|
||||
)
|
||||
self._confirm_box.set_halign(Gtk.Align.CENTER)
|
||||
self._confirm_box.add_css_class("confirm-box")
|
||||
|
||||
prompt = action_def.get_confirm_prompt(self._strings)
|
||||
confirm_label = Gtk.Label(label=prompt)
|
||||
confirm_label.add_css_class("confirm-label")
|
||||
self._confirm_box.append(confirm_label)
|
||||
|
||||
button_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=8
|
||||
)
|
||||
button_box.set_halign(Gtk.Align.CENTER)
|
||||
|
||||
yes_btn = Gtk.Button(label=self._strings.confirm_yes)
|
||||
yes_btn.add_css_class("confirm-yes")
|
||||
yes_btn.connect(
|
||||
"clicked", lambda _: self._execute_action(action_def)
|
||||
)
|
||||
button_box.append(yes_btn)
|
||||
|
||||
no_btn = Gtk.Button(label=self._strings.confirm_no)
|
||||
no_btn.add_css_class("confirm-no")
|
||||
no_btn.connect("clicked", lambda _: self._dismiss_confirm())
|
||||
button_box.append(no_btn)
|
||||
|
||||
self._confirm_box.append(button_box)
|
||||
self._confirm_area.append(self._confirm_box)
|
||||
|
||||
# Focus the "No" button — safe default for keyboard navigation
|
||||
no_btn.grab_focus()
|
||||
|
||||
def _dismiss_confirm(self) -> None:
|
||||
"""Remove the confirmation prompt."""
|
||||
if self._confirm_box is not None:
|
||||
self._confirm_area.remove(self._confirm_box)
|
||||
self._confirm_box = None
|
||||
|
||||
def _execute_action(self, action_def: ActionDef) -> None:
|
||||
"""Execute a power action in a background thread."""
|
||||
self._dismiss_confirm()
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
action_def.action_fn()
|
||||
# Lock action: quit after successful execution
|
||||
if action_def.name == "lock":
|
||||
GLib.idle_add(self._app.quit)
|
||||
except Exception:
|
||||
logger.exception("Power action '%s' failed", action_def.name)
|
||||
error_msg = action_def.get_error_message(self._strings)
|
||||
GLib.idle_add(self._show_error, error_msg)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _set_avatar_from_file(self, path: Path) -> None:
|
||||
"""Load an image file and set it as the avatar."""
|
||||
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 _set_default_avatar(self) -> None:
|
||||
"""Load the default avatar SVG, tinted with the foreground color."""
|
||||
try:
|
||||
default_path = get_default_avatar_path()
|
||||
svg_text = default_path.read_text()
|
||||
rgba = self.get_color()
|
||||
fg_color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}"
|
||||
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 _show_error(self, message: str) -> None:
|
||||
"""Display an error message."""
|
||||
self._error_label.set_text(message)
|
||||
self._error_label.set_visible(True)
|
||||
@@ -1,34 +0,0 @@
|
||||
# ABOUTME: Power actions — lock, logout, hibernate, reboot, shutdown.
|
||||
# ABOUTME: Simple wrappers around system commands for the session power menu.
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
POWER_TIMEOUT = 30
|
||||
|
||||
|
||||
def lock() -> None:
|
||||
"""Lock the current session by launching moonlock."""
|
||||
subprocess.run(["moonlock"], check=True, timeout=POWER_TIMEOUT)
|
||||
|
||||
|
||||
def logout() -> None:
|
||||
"""Quit the Niri compositor (logout)."""
|
||||
subprocess.run(
|
||||
["niri", "msg", "action", "quit"], check=True, timeout=POWER_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def hibernate() -> None:
|
||||
"""Hibernate the system via systemctl."""
|
||||
subprocess.run(["systemctl", "hibernate"], check=True, timeout=POWER_TIMEOUT)
|
||||
|
||||
|
||||
def reboot() -> None:
|
||||
"""Reboot the system via loginctl."""
|
||||
subprocess.run(["loginctl", "reboot"], check=True, timeout=POWER_TIMEOUT)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Shut down the system via loginctl."""
|
||||
subprocess.run(["loginctl", "poweroff"], check=True, timeout=POWER_TIMEOUT)
|
||||
@@ -1,105 +0,0 @@
|
||||
/* ABOUTME: GTK4 CSS stylesheet for the Moonset power menu. */
|
||||
/* ABOUTME: Uses GTK theme colors for consistency with the active desktop theme. */
|
||||
|
||||
/* Main panel window background */
|
||||
window.panel {
|
||||
background-color: @theme_bg_color;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Wallpaper-only window for secondary monitors */
|
||||
window.wallpaper {
|
||||
background-color: @theme_bg_color;
|
||||
}
|
||||
|
||||
/* Round avatar image */
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
min-width: 128px;
|
||||
min-height: 128px;
|
||||
background-color: @theme_selected_bg_color;
|
||||
border: 3px solid alpha(white, 0.3);
|
||||
}
|
||||
|
||||
/* Username label */
|
||||
.username-label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
/* Action button — square card */
|
||||
.action-button {
|
||||
min-width: 120px;
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
border-radius: 50%;
|
||||
background-color: alpha(@theme_base_color, 0.55);
|
||||
color: @theme_fg_color;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: alpha(@theme_base_color, 0.7);
|
||||
}
|
||||
|
||||
/* Action icon inside button — request 48px from theme, scale up via CSS */
|
||||
.action-icon {
|
||||
color: @theme_fg_color;
|
||||
-gtk-icon-size: 64px;
|
||||
}
|
||||
|
||||
/* Action label below icon */
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
color: @theme_unfocused_fg_color;
|
||||
}
|
||||
|
||||
/* Confirmation box below action buttons */
|
||||
.confirm-box {
|
||||
padding: 16px 24px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Confirmation prompt text */
|
||||
.confirm-label {
|
||||
font-size: 16px;
|
||||
color: @theme_fg_color;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Confirm "Yes" button */
|
||||
.confirm-yes {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: @error_color;
|
||||
color: @theme_bg_color;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.confirm-yes:hover {
|
||||
background-color: lighter(@error_color);
|
||||
}
|
||||
|
||||
/* Confirm "No/Cancel" button */
|
||||
.confirm-no {
|
||||
padding: 8px 24px;
|
||||
border-radius: 8px;
|
||||
background-color: @theme_unfocused_bg_color;
|
||||
color: @theme_fg_color;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.confirm-no:hover {
|
||||
background-color: @theme_selected_bg_color;
|
||||
}
|
||||
|
||||
/* Error message label */
|
||||
.error-label {
|
||||
color: @error_color;
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
# ABOUTME: Current user detection and avatar loading for the power menu.
|
||||
# ABOUTME: Retrieves user info from the system (pwd, AccountsService, ~/.face).
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class User:
|
||||
"""Represents the current user for the power menu."""
|
||||
|
||||
username: str
|
||||
display_name: str
|
||||
home: Path
|
||||
uid: int
|
||||
|
||||
|
||||
def get_current_user() -> User:
|
||||
"""Get the currently logged-in user's info from the system."""
|
||||
# Use getuid() instead of getlogin() — getlogin() fails without a controlling
|
||||
# terminal (systemd units, display-manager-started sessions).
|
||||
pw = pwd.getpwuid(os.getuid())
|
||||
|
||||
gecos = pw.pw_gecos
|
||||
# GECOS field may contain comma-separated values; first field is the full name
|
||||
display_name = gecos.split(",")[0] if gecos else pw.pw_name
|
||||
if not display_name:
|
||||
display_name = pw.pw_name
|
||||
|
||||
return User(
|
||||
username=pw.pw_name,
|
||||
display_name=display_name,
|
||||
home=Path(pw.pw_dir),
|
||||
uid=pw.pw_uid,
|
||||
)
|
||||
|
||||
|
||||
def get_avatar_path(
|
||||
home: Path,
|
||||
username: str | None = None,
|
||||
accountsservice_dir: Path = DEFAULT_ACCOUNTSSERVICE_DIR,
|
||||
) -> Path | None:
|
||||
"""Find the user's avatar image, checking ~/.face then AccountsService."""
|
||||
# ~/.face takes priority
|
||||
face = home / ".face"
|
||||
if face.exists():
|
||||
return face
|
||||
|
||||
# AccountsService icon
|
||||
if username and accountsservice_dir.exists():
|
||||
icon = accountsservice_dir / username
|
||||
if icon.exists():
|
||||
return icon
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_default_avatar_path() -> Path:
|
||||
"""Return the path to the package default avatar SVG."""
|
||||
return Path(str(files("moonset") / "data" / "default-avatar.svg"))
|
||||
Reference in New Issue
Block a user