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 = {
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 {
+24 -10
View File
@@ -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():
+1 -1
View File
@@ -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(
+73 -47
View File
@@ -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.<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 = 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
+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.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