diff --git a/flake.nix b/flake.nix index ba055c7..9f24848 100644 --- a/flake.nix +++ b/flake.nix @@ -118,7 +118,16 @@ default = { enable = false; }; - description = "Stylix configuration passed from the stylix module"; + description = '' + Stylix configuration passed from the stylix module. + + Optionally set `variants.light.colors` / `variants.dark.colors` + (base16 slots base00..base0F, hex without `#`). When present, + sims follows the XDG desktop portal's color-scheme setting + (org.freedesktop.appearance, e.g. served by darkman) and + swaps palettes live; slots missing from a variant fall back + to `colors`. + ''; }; calendar = { enable = lib.mkOption { diff --git a/sims/main.py b/sims/main.py index b267d37..94cab6a 100644 --- a/sims/main.py +++ b/sims/main.py @@ -31,7 +31,7 @@ from .modules.calendar import CalendarService from .modules.control_center import ControlCenter from .modules.notifications import NotificationToasts from .modules.org import OrgDropdown -from .modules.stylix import get_stylix_css_path +from .modules.stylix import generate_stylix_css from .modules.vinyl import VinylButton from .services.org import OrgService from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL @@ -39,6 +39,9 @@ from .services.fenster import get_i3_connection from .services.capture import get_capture_service from .services.notification_history import NotificationHistoryService from .services.screenrec import ScreenrecService +from .services.theme import get_theme_service + +from gi.repository import GLib, Gtk from fabric.notifications import Notifications @@ -216,18 +219,29 @@ def toggle_bar_corners(): _set_all_bars_rounded(new_state) # Load CSS - use Stylix if enabled, otherwise use default +app.set_stylesheet_from_file(get_relative_path("styles/main.css")) + if STYLIX.get("enable", False): - stylix_css_path = get_stylix_css_path() - if stylix_css_path: - logger.info("[Bar] Using Stylix CSS") - app.set_stylesheet_from_file(get_relative_path("styles/main.css")) - app.set_stylesheet_from_file(stylix_css_path, append=True) - else: - logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default") - app.set_stylesheet_from_file(get_relative_path("styles/main.css")) + # Own the stylix provider so a light/dark switch can reload its data in + # place; GTK restyles every widget when a provider changes. + stylix_provider = Gtk.CssProvider() + theme_service = get_theme_service() + + def _load_stylix_css(mode=None): + css = generate_stylix_css(mode) + if css is None: + return + try: + stylix_provider.load_from_data(css.encode()) + except GLib.Error as e: + logger.error(f"[Bar] Failed to load Stylix CSS: {e.message}") + + _load_stylix_css(theme_service.mode) + app.add_style_provider(stylix_provider) + theme_service.connect("changed", lambda _svc, mode: _load_stylix_css(mode)) + logger.info("[Bar] Using Stylix CSS") else: logger.info("[Bar] Using default CSS") - app.set_stylesheet_from_file(get_relative_path("styles/main.css")) def spawn_bars(): diff --git a/sims/modules/bar.py b/sims/modules/bar.py index f666a29..e1202fd 100644 --- a/sims/modules/bar.py +++ b/sims/modules/bar.py @@ -70,7 +70,7 @@ class StatusBar(Window): self.active_window = FensterActiveWindow( name="active-window", max_length=50, - style="color: #ffffff; font-size: 14px; font-weight: bold;", + style="font-size: 14px; font-weight: bold;", ) self.ram_progress_bar = CircularProgressBar( diff --git a/sims/modules/stylix.py b/sims/modules/stylix.py index 65ab905..8b4bbd7 100644 --- a/sims/modules/stylix.py +++ b/sims/modules/stylix.py @@ -1,41 +1,73 @@ from sims.config import STYLIX -import tempfile -import os +from loguru import logger -def generate_stylix_css(): - """Generate CSS using Stylix colors if enabled""" +# Default colors if Stylix is not properly configured +DEFAULT_COLORS = { + "base00": "1e1e2e", # background + "base01": "313244", # lighter background + "base02": "45475a", # selection background + "base03": "585b70", # comments + "base04": "bac2de", # dark foreground + "base05": "cdd6f4", # foreground + "base06": "f5e0dc", # light foreground + "base07": "b4befe", # light background + "base08": "f38ba8", # red + "base09": "fab387", # orange + "base0A": "f9e2af", # yellow + "base0B": "a6e3a1", # green + "base0C": "94e2d5", # cyan + "base0D": "89b4fa", # blue + "base0E": "cba6f7", # purple + "base0F": "f2cdcd", # brown +} + +# styles/colors.css palette name -> base16 slot. fabric compiles `:vars` to +# GTK `@define-color`, and named colors resolve across the whole style +# cascade, so redefining them in the stylix provider (added after main.css) +# retargets every `var(--x)` rule in styles/*.css as well -- not just the +# selectors overridden below. +PALETTE = { + "background": "base00", + "mid-bg": "base01", + "light-bg": "base02", + "dark-grey": "base03", + "light-grey": "base04", + "dark-fg": "base04", + "mid-fg": "base05", + "foreground": "base05", + "red": "base08", + "orange": "base09", + "gold": "base0A", + "lime": "base0B", + "turquoise": "base0C", + "blue": "base0D", + "violet": "base0E", + "pink": "base0F", +} + + +def resolve_colors(mode: str | None = None) -> dict[str, str]: + """base16 colors for `mode` ("light"/"dark"): the matching + `stylix.variants..colors`, then the base `stylix.colors`, then + DEFAULT_COLORS for any slot still missing.""" + colors = dict(DEFAULT_COLORS) + if STYLIX.get("enable", False): + colors.update(STYLIX.get("colors") or {}) + if mode is not None: + variant = (STYLIX.get("variants") or {}).get(mode) or {} + colors.update(variant.get("colors") or {}) + return colors + + +def generate_stylix_css(mode: str | None = None) -> str | None: + """Generate CSS using Stylix colors (for `mode`, if variants are set)""" if not STYLIX.get("enable", False): return None - colors = STYLIX.get("colors", {}) + colors = resolve_colors(mode) fonts = STYLIX.get("fonts", {}) - # Default colors if Stylix is not properly configured - default_colors = { - "base00": "1e1e2e", # background - "base01": "313244", # lighter background - "base02": "45475a", # selection background - "base03": "585b70", # comments - "base04": "bac2de", # dark foreground - "base05": "cdd6f4", # foreground - "base06": "f5e0dc", # light foreground - "base07": "b4befe", # light background - "base08": "f38ba8", # red - "base09": "fab387", # orange - "base0A": "f9e2af", # yellow - "base0B": "a6e3a1", # green - "base0C": "94e2d5", # cyan - "base0D": "89b4fa", # blue - "base0E": "cba6f7", # purple - "base0F": "f2cdcd", # brown - } - - # Use Stylix colors or fallback to defaults - for key in default_colors: - if key not in colors: - colors[key] = default_colors[key] - # Default font font_family = fonts.get("sansSerif", "sans-serif") font_sizes = fonts.get("sizes", {}) @@ -46,13 +78,20 @@ def generate_stylix_css(): small_font = max(int(font_size * 0.85), 10) # Minimum 10px large_font = int(font_size * 1.1) - # Debug logging - from loguru import logger - logger.info(f"[Stylix] Using font sizes - Base: {font_size}px, Small: {small_font}px, Large: {large_font}px") + logger.info( + f"[Stylix] Generating CSS (mode={mode}) - Base: {font_size}px, " + f"Small: {small_font}px, Large: {large_font}px" + ) + + palette = "\n".join( + f"@define-color {name} #{colors[slot]};" for name, slot in PALETTE.items() + ) # Generate GTK CSS with Stylix colors css_content = f"""/* Stylix-generated theme */ +{palette} + /* Apply Stylix font */ * {{ font-family: "{font_family}", sans-serif; @@ -459,17 +498,4 @@ tooltip>* {{ }} """ - # Write to temporary file - temp_fd, temp_path = tempfile.mkstemp(suffix=".css", prefix="stylix_") - try: - with os.fdopen(temp_fd, "w") as f: - f.write(css_content) - return temp_path - except Exception: - os.close(temp_fd) - return None - - -def get_stylix_css_path(): - """Get the path to the Stylix CSS file""" - return generate_stylix_css() + return css_content diff --git a/sims/services/theme.py b/sims/services/theme.py new file mode 100644 index 0000000..510a220 --- /dev/null +++ b/sims/services/theme.py @@ -0,0 +1,122 @@ +"""Light/dark theme mode service. + +Follows the XDG desktop portal's `org.freedesktop.appearance color-scheme` +setting (served by darkman, GNOME, KDE, ...) and emits `changed` whenever the +effective mode flips. The mode is one of: + + - "dark" -- portal reports prefer-dark (1) + - "light" -- portal reports prefer-light (2) + - None -- no preference (0) or no portal available; callers fall back + to the base stylix palette. + +The portal is only queried when `stylix.variants` is configured, so setups +without light/dark palettes never touch DBus here. +""" + +from fabric.core.service import Service, Signal +from gi.repository import Gio, GLib +from loguru import logger + +from sims.config import STYLIX + +PORTAL_BUS = "org.freedesktop.portal.Desktop" +PORTAL_PATH = "/org/freedesktop/portal/desktop" +SETTINGS_IFACE = "org.freedesktop.portal.Settings" +APPEARANCE_NS = "org.freedesktop.appearance" +COLOR_SCHEME_KEY = "color-scheme" + +_SCHEME_TO_MODE = {1: "dark", 2: "light"} + +_service: "ThemeService | None" = None + + +def _unwrap(value): + """Strip nested GLib.Variant layers (`Read` wraps the value twice).""" + while isinstance(value, GLib.Variant): + value = value.unpack() + return value + + +class ThemeService(Service): + @Signal + def changed(self, mode: object) -> None: + """Emitted with the new mode ("light", "dark" or None).""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._mode: str | None = None + self._bus: Gio.DBusConnection | None = None + + if not STYLIX.get("enable", False) or not STYLIX.get("variants"): + return + + try: + self._bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + except GLib.Error as e: + logger.warning(f"[Theme] no session bus, not following color-scheme: {e}") + return + + self._bus.signal_subscribe( + PORTAL_BUS, + SETTINGS_IFACE, + "SettingChanged", + PORTAL_PATH, + None, + Gio.DBusSignalFlags.NONE, + self._on_setting_changed, + ) + # Async so a slow portal activation at session start can't stall the + # bar; the palette is swapped in as soon as the reply lands. + self._read("ReadOne") + + @property + def mode(self) -> str | None: + return self._mode + + def _read(self, method: str): + assert self._bus is not None + self._bus.call( + PORTAL_BUS, + PORTAL_PATH, + SETTINGS_IFACE, + method, + GLib.Variant("(ss)", (APPEARANCE_NS, COLOR_SCHEME_KEY)), + None, + Gio.DBusCallFlags.NONE, + -1, + None, + self._on_read, + method, + ) + + def _on_read(self, bus: Gio.DBusConnection, result: Gio.AsyncResult, method: str): + try: + reply = bus.call_finish(result) + except GLib.Error as e: + if method == "ReadOne": + # Settings portal < v2 only has the (deprecated) Read. + self._read("Read") + return + logger.warning(f"[Theme] could not read color-scheme from portal: {e.message}") + return + self._set_scheme(_unwrap(reply.get_child_value(0))) + + def _on_setting_changed(self, _bus, _sender, _path, _iface, _signal, params: GLib.Variant): + namespace, key, value = params.unpack() + if namespace == APPEARANCE_NS and key == COLOR_SCHEME_KEY: + self._set_scheme(_unwrap(value)) + + def _set_scheme(self, scheme): + mode = _SCHEME_TO_MODE.get(scheme) if isinstance(scheme, int) else None + if mode == self._mode: + return + logger.info(f"[Theme] color-scheme={scheme!r} -> mode={mode}") + self._mode = mode + self.changed(mode) + + +def get_theme_service() -> ThemeService: + global _service + if _service is None: + _service = ThemeService() + return _service diff --git a/sims/widgets/buddy.py b/sims/widgets/buddy.py index e673280..691fa27 100644 --- a/sims/widgets/buddy.py +++ b/sims/widgets/buddy.py @@ -3,7 +3,8 @@ from fabric.widgets.box import Box from fabric.widgets.image import Image from fabric.widgets.button import Button -from sims.config import STYLIX +from sims.modules.stylix import resolve_colors +from sims.services.theme import get_theme_service # 16x16 sprite grid, displayed at SCALE x scale (32x32) with crisp pixels. @@ -252,15 +253,8 @@ def _mix(c1: tuple[int, int, int], c2: tuple[int, int, int], t: float) -> tuple[ int(c1[2] * (1 - t) + c2[2] * t)) -def _resolve_palette() -> dict[str, tuple[int, int, int, int]]: - defaults = { - "base00": "1e1e2e", "base05": "cdd6f4", "base07": "b4befe", - "base08": "f38ba8", "base0A": "f9e2af", "base0C": "94e2d5", - "base0D": "89b4fa", - } - colors = dict(STYLIX.get("colors", {})) if STYLIX.get("enable", False) else {} - for k, v in defaults.items(): - colors.setdefault(k, v) +def _resolve_palette(mode: str | None = None) -> dict[str, tuple[int, int, int, int]]: + colors = resolve_colors(mode) body_rgba = _hex_to_rgba(colors["base0D"]) body_rgb = body_rgba[:3] @@ -314,7 +308,9 @@ def _frame_to_pixbuf(rows: list[str], palette: dict[str, tuple[int, int, int, in class BuddyWidget(Box): def __init__(self, on_pet=None, **kwargs): super().__init__(name="buddy", v_align="center", **kwargs) - self._palette = _resolve_palette() + theme = get_theme_service() + self._palette = _resolve_palette(theme.mode) + theme.connect("changed", self._on_theme_changed) self._cache: dict[int, GdkPixbuf.Pixbuf] = {} self._mood = "idle" self._frame_idx = 0 @@ -382,6 +378,11 @@ class BuddyWidget(Box): if callable(self._on_pet): self._on_pet(petted=True) + def _on_theme_changed(self, _theme, mode): + self._palette = _resolve_palette(mode) + self._cache.clear() + self._render_current() + def update_mood(self, mood: str): if mood == self._mood: return