feat: support theme switching
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
+24
-10
@@ -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.
|
||||||
logger.info("[Bar] Using Stylix CSS")
|
stylix_provider = Gtk.CssProvider()
|
||||||
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
theme_service = get_theme_service()
|
||||||
app.set_stylesheet_from_file(stylix_css_path, append=True)
|
|
||||||
else:
|
def _load_stylix_css(mode=None):
|
||||||
logger.warning("[Bar] Stylix enabled but CSS generation failed, falling back to default")
|
css = generate_stylix_css(mode)
|
||||||
app.set_stylesheet_from_file(get_relative_path("styles/main.css"))
|
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:
|
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
@@ -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(
|
||||||
|
|||||||
+73
-47
@@ -1,41 +1,73 @@
|
|||||||
from sims.config import STYLIX
|
from sims.config import STYLIX
|
||||||
import tempfile
|
from loguru import logger
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def generate_stylix_css():
|
# Default colors if Stylix is not properly configured
|
||||||
"""Generate CSS using Stylix colors if enabled"""
|
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):
|
if not STYLIX.get("enable", False):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
colors = STYLIX.get("colors", {})
|
colors = resolve_colors(mode)
|
||||||
fonts = STYLIX.get("fonts", {})
|
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
|
# Default font
|
||||||
font_family = fonts.get("sansSerif", "sans-serif")
|
font_family = fonts.get("sansSerif", "sans-serif")
|
||||||
font_sizes = fonts.get("sizes", {})
|
font_sizes = fonts.get("sizes", {})
|
||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|||||||
Reference in New Issue
Block a user