feat: support theme switching

This commit is contained in:
2026-10-05 12:01:17 +02:00
parent 42a4ffdee6
commit fc4fa21a0f
6 changed files with 242 additions and 70 deletions
+10 -1
View File
@@ -118,7 +118,16 @@
default = { default = {
enable = false; 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 = { calendar = {
enable = lib.mkOption { enable = lib.mkOption {
+23 -9
View File
@@ -31,7 +31,7 @@ from .modules.calendar import CalendarService
from .modules.control_center import ControlCenter from .modules.control_center import ControlCenter
from .modules.notifications import NotificationToasts from .modules.notifications import NotificationToasts
from .modules.org import OrgDropdown 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 .modules.vinyl import VinylButton
from .services.org import OrgService from .services.org import OrgService
from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL 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.capture import get_capture_service
from .services.notification_history import NotificationHistoryService from .services.notification_history import NotificationHistoryService
from .services.screenrec import ScreenrecService from .services.screenrec import ScreenrecService
from .services.theme import get_theme_service
from gi.repository import GLib, Gtk
from fabric.notifications import Notifications from fabric.notifications import Notifications
@@ -216,18 +219,29 @@ def toggle_bar_corners():
_set_all_bars_rounded(new_state) _set_all_bars_rounded(new_state)
# Load CSS - use Stylix if enabled, otherwise use default # 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): if STYLIX.get("enable", False):
stylix_css_path = get_stylix_css_path() # Own the stylix provider so a light/dark switch can reload its data in
if stylix_css_path: # 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") 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"))
else: else:
logger.info("[Bar] Using default CSS") logger.info("[Bar] Using default CSS")
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
def spawn_bars(): def spawn_bars():
+1 -1
View File
@@ -70,7 +70,7 @@ class StatusBar(Window):
self.active_window = FensterActiveWindow( self.active_window = FensterActiveWindow(
name="active-window", name="active-window",
max_length=50, max_length=50,
style="color: #ffffff; font-size: 14px; font-weight: bold;", style="font-size: 14px; font-weight: bold;",
) )
self.ram_progress_bar = CircularProgressBar( self.ram_progress_bar = CircularProgressBar(
+58 -32
View File
@@ -1,18 +1,9 @@
from sims.config import STYLIX from sims.config import STYLIX
import tempfile from loguru import logger
import os
def generate_stylix_css():
"""Generate CSS using Stylix colors if enabled"""
if not STYLIX.get("enable", False):
return None
colors = STYLIX.get("colors", {})
fonts = STYLIX.get("fonts", {})
# Default colors if Stylix is not properly configured # Default colors if Stylix is not properly configured
default_colors = { DEFAULT_COLORS = {
"base00": "1e1e2e", # background "base00": "1e1e2e", # background
"base01": "313244", # lighter background "base01": "313244", # lighter background
"base02": "45475a", # selection background "base02": "45475a", # selection background
@@ -31,10 +22,51 @@ def generate_stylix_css():
"base0F": "f2cdcd", # brown "base0F": "f2cdcd", # brown
} }
# Use Stylix colors or fallback to defaults # styles/colors.css palette name -> base16 slot. fabric compiles `:vars` to
for key in default_colors: # GTK `@define-color`, and named colors resolve across the whole style
if key not in colors: # cascade, so redefining them in the stylix provider (added after main.css)
colors[key] = default_colors[key] # 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.<mode>.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 = resolve_colors(mode)
fonts = STYLIX.get("fonts", {})
# Default font # Default font
font_family = fonts.get("sansSerif", "sans-serif") font_family = fonts.get("sansSerif", "sans-serif")
@@ -46,13 +78,20 @@ def generate_stylix_css():
small_font = max(int(font_size * 0.85), 10) # Minimum 10px small_font = max(int(font_size * 0.85), 10) # Minimum 10px
large_font = int(font_size * 1.1) large_font = int(font_size * 1.1)
# Debug logging logger.info(
from loguru import logger f"[Stylix] Generating CSS (mode={mode}) - Base: {font_size}px, "
logger.info(f"[Stylix] Using font sizes - Base: {font_size}px, Small: {small_font}px, Large: {large_font}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 # Generate GTK CSS with Stylix colors
css_content = f"""/* Stylix-generated theme */ css_content = f"""/* Stylix-generated theme */
{palette}
/* Apply Stylix font */ /* Apply Stylix font */
* {{ * {{
font-family: "{font_family}", sans-serif; font-family: "{font_family}", sans-serif;
@@ -459,17 +498,4 @@ tooltip>* {{
}} }}
""" """
# Write to temporary file return css_content
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()
+122
View File
@@ -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
+12 -11
View File
@@ -3,7 +3,8 @@ from fabric.widgets.box import Box
from fabric.widgets.image import Image from fabric.widgets.image import Image
from fabric.widgets.button import Button 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. # 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)) int(c1[2] * (1 - t) + c2[2] * t))
def _resolve_palette() -> dict[str, tuple[int, int, int, int]]: def _resolve_palette(mode: str | None = None) -> dict[str, tuple[int, int, int, int]]:
defaults = { colors = resolve_colors(mode)
"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)
body_rgba = _hex_to_rgba(colors["base0D"]) body_rgba = _hex_to_rgba(colors["base0D"])
body_rgb = body_rgba[:3] 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): class BuddyWidget(Box):
def __init__(self, on_pet=None, **kwargs): def __init__(self, on_pet=None, **kwargs):
super().__init__(name="buddy", v_align="center", **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._cache: dict[int, GdkPixbuf.Pixbuf] = {}
self._mood = "idle" self._mood = "idle"
self._frame_idx = 0 self._frame_idx = 0
@@ -382,6 +378,11 @@ class BuddyWidget(Box):
if callable(self._on_pet): if callable(self._on_pet):
self._on_pet(petted=True) 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): def update_mood(self, mood: str):
if mood == self._mood: if mood == self._mood:
return return