feat: support theme switching
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user