From 4274971337be60158d41ba281b1cabed56238ef1 Mon Sep 17 00:00:00 2001 From: Makesesama Date: Mon, 5 Oct 2026 12:09:28 +0200 Subject: [PATCH] feat: manual darkman switching --- flake.nix | 18 ++++ sims/cli.py | 19 ++++ sims/config.py | 1 + sims/main.py | 26 ++++- sims/modules/control_center.py | 53 ++++++---- sims/modules/stylix.py | 27 ++++++ sims/modules/theme_toggle.py | 39 ++++++++ sims/services/darkman.py | 171 +++++++++++++++++++++++++++++++++ sims/styles/vinyl.css | 32 ++++++ 9 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 sims/modules/theme_toggle.py create mode 100644 sims/services/darkman.py diff --git a/flake.nix b/flake.nix index 9f24848..3c129c2 100644 --- a/flake.nix +++ b/flake.nix @@ -75,6 +75,23 @@ default = false; }; }; + darkman = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Add a light/dark mode toggle to the control center and the + `theme-toggle` / `theme-set-light` / `theme-set-dark` actions + (`sims-cli theme toggle|light|dark`). Switches darkman via its + control socket. + ''; + }; + socket_path = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "darkman control socket; null means \${XDG_RUNTIME_DIR}/darkman/control.sock"; + }; + }; battery = { enable = lib.mkOption { type = lib.types.bool; @@ -314,6 +331,7 @@ }; default = { vinyl.enable = false; + darkman.enable = false; battery.enable = false; buddy.enable = false; height = 40; diff --git a/sims/cli.py b/sims/cli.py index 526ffa2..d6470ca 100644 --- a/sims/cli.py +++ b/sims/cli.py @@ -93,6 +93,15 @@ def _cmd_corners(ns: argparse.Namespace) -> None: invoke_action(mapping[ns.corners_cmd]) +def _cmd_theme(ns: argparse.Namespace) -> None: + mapping = { + "toggle": "theme-toggle", + "light": "theme-set-light", + "dark": "theme-set-dark", + } + invoke_action(mapping[ns.theme_cmd]) + + def _cmd_list(ns: argparse.Namespace) -> None: actions = list_actions() if ns.json: @@ -205,6 +214,16 @@ def build_parser() -> argparse.ArgumentParser: corners_sub.add_parser(sub_name, help=sub_help) corners.set_defaults(func=_cmd_corners) + theme = sub.add_parser("theme", help="light/dark mode (via darkman)") + theme_sub = theme.add_subparsers(dest="theme_cmd", required=True, metavar="MODE") + for sub_name, sub_help in [ + ("toggle", "flip between light and dark mode"), + ("light", "switch to light mode"), + ("dark", "switch to dark mode"), + ]: + theme_sub.add_parser(sub_name, help=sub_help) + theme.set_defaults(func=_cmd_theme) + lst = sub.add_parser("list", help="list registered actions") lst.add_argument("--json", action="store_true", help="emit JSON array") lst.set_defaults(func=_cmd_list) diff --git a/sims/config.py b/sims/config.py index 9b7fbae..886f2b4 100644 --- a/sims/config.py +++ b/sims/config.py @@ -50,6 +50,7 @@ if app_config is None: raise Exception("Config file missing") VINYL = app_config.get("vinyl", {"enable": False}) +DARKMAN = app_config.get("darkman", {"enable": False}) BATTERY = app_config.get("battery", {"enable": False}) WINDOW_TITLE = app_config.get("window_title", {"enable": True}) STYLIX = app_config.get("stylix", {"enable": False}) diff --git a/sims/main.py b/sims/main.py index 94cab6a..308441c 100644 --- a/sims/main.py +++ b/sims/main.py @@ -32,9 +32,11 @@ from .modules.control_center import ControlCenter from .modules.notifications import NotificationToasts from .modules.org import OrgDropdown from .modules.stylix import generate_stylix_css +from .modules.theme_toggle import ThemeToggleButton from .modules.vinyl import VinylButton from .services.org import OrgService -from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL +from .config import CALENDAR, DARKMAN, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL +from .services.darkman import get_darkman_service from .services.fenster import get_i3_connection from .services.capture import get_capture_service from .services.notification_history import NotificationHistoryService @@ -89,6 +91,9 @@ if NOTIFICATIONS.get("enable", False): ) vinyl_button: VinylButton | None = VinylButton() if VINYL.get("enable", False) else None +theme_button: ThemeToggleButton | None = ( + ThemeToggleButton(get_darkman_service()) if DARKMAN.get("enable", False) else None +) calendar_service: CalendarService | None = ( CalendarService(update_interval=120000) if CALENDAR.get("enable", True) else None ) @@ -99,6 +104,7 @@ if notification_history is not None: history=notification_history, calendar_service=calendar_service, vinyl_button=vinyl_button, + theme_button=theme_button, monitor=0, width=NOTIFICATIONS.get("center_width", 380), ) @@ -198,6 +204,24 @@ def toggle_control_center(): control_center.toggle() +@Application.action() +def theme_toggle(): + if DARKMAN.get("enable", False): + get_darkman_service().toggle() + + +@Application.action() +def theme_set_light(): + if DARKMAN.get("enable", False): + get_darkman_service().set_mode("light") + + +@Application.action() +def theme_set_dark(): + if DARKMAN.get("enable", False): + get_darkman_service().set_mode("dark") + + def _set_all_bars_rounded(rounded: bool): for bar in bar_windows: bar.set_corners_rounded(rounded) diff --git a/sims/modules/control_center.py b/sims/modules/control_center.py index c20396a..ba256d6 100644 --- a/sims/modules/control_center.py +++ b/sims/modules/control_center.py @@ -9,6 +9,7 @@ from fabric.widgets.wayland import WaylandWindow as Window from gi.repository import Gdk from sims.modules.calendar import CalendarService +from sims.modules.theme_toggle import ThemeToggleButton from sims.modules.vinyl import VinylButton from sims.services.notification_history import NotificationHistoryService from sims.widgets.notification_history_entry import NotificationHistoryEntryWidget @@ -20,6 +21,7 @@ class ControlCenter(Window): history: NotificationHistoryService, calendar_service: CalendarService | None = None, vinyl_button: VinylButton | None = None, + theme_button: ThemeToggleButton | None = None, monitor: int = 0, width: int = 380, ): @@ -35,6 +37,7 @@ class ControlCenter(Window): self._history = history self._calendar_service = calendar_service self._vinyl_button = vinyl_button + self._theme_button = theme_button self._width = width self._visibility_listeners: list = [] @@ -58,8 +61,16 @@ class ControlCenter(Window): sections: list = [] - if vinyl_button is not None: - sections.append(self._build_settings_section()) + settings_rows = [ + (label, widget) + for label, widget in ( + ("Dark Mode", theme_button), + ("Vinyl Passthrough", vinyl_button), + ) + if widget is not None + ] + if settings_rows: + sections.append(self._build_settings_section(settings_rows)) if calendar_service is not None: sections.append(self._build_calendar_section()) @@ -123,26 +134,26 @@ class ControlCenter(Window): ) return section - def _build_settings_section(self) -> Box: + def _build_settings_section(self, rows: list) -> Box: section = self._build_section("control-center-settings", "Settings") - - row = Box( - name="control-center-settings-row", - orientation="h", - spacing=8, - ) - row.pack_start( - Label( - name="control-center-settings-label", - label="Vinyl Passthrough", - h_align="start", - ), - True, - True, - 0, - ) - row.pack_end(self._vinyl_button, False, False, 0) - section.add(row) + for label, widget in rows: + row = Box( + name="control-center-settings-row", + orientation="h", + spacing=8, + ) + row.pack_start( + Label( + name="control-center-settings-label", + label=label, + h_align="start", + ), + True, + True, + 0, + ) + row.pack_end(widget, False, False, 0) + section.add(row) return section def _build_calendar_section(self) -> Box: diff --git a/sims/modules/stylix.py b/sims/modules/stylix.py index 8b4bbd7..92dd0dc 100644 --- a/sims/modules/stylix.py +++ b/sims/modules/stylix.py @@ -430,6 +430,33 @@ tooltip>* {{ color: inherit; }} +/* Theme (darkman light/dark) toggle */ +#theme-toggle-button {{ + background-color: transparent; + color: #{colors["base05"]}; + border: none; + padding: 4px; + margin: 0px; + border-radius: 8px; +}} + +#theme-toggle-button:hover {{ + background-color: #{colors["base02"]}; +}} + +#theme-toggle-button.active {{ + background-color: #{colors["base0D"]}; + color: #{colors["base00"]}; +}} + +#theme-toggle-button:disabled {{ + opacity: 0.4; +}} + +#theme-toggle-icon {{ + color: inherit; +}} + /* Toggle switch styling for quick menu */ .toggle-active {{ background-color: #{colors["base0B"]}; diff --git a/sims/modules/theme_toggle.py b/sims/modules/theme_toggle.py new file mode 100644 index 0000000..4736d46 --- /dev/null +++ b/sims/modules/theme_toggle.py @@ -0,0 +1,39 @@ +from fabric.widgets.button import Button +from fabric.widgets.image import Image + +from sims.services.darkman import DarkmanService + + +class ThemeToggleButton(Button): + """Flips darkman between light and dark mode. + + Shows the active mode (moon + `active` class in dark mode) and is + insensitive while darkman is unreachable. + """ + + def __init__(self, service: DarkmanService, **kwargs): + self._service = service + self.icon = Image(icon_size=16, name="theme-toggle-icon") + super().__init__( + name="theme-toggle-button", + child=self.icon, + on_clicked=lambda *_: self._service.toggle(), + **kwargs, + ) + self._service.connect("changed", lambda _svc, mode: self._update(mode)) + self._update(self._service.mode) + + def _update(self, mode: str | None): + dark = mode == "dark" + self.icon.set_from_icon_name( + "weather-clear-night-symbolic" if dark else "weather-clear-symbolic", 16 + ) + if dark: + self.add_style_class("active") + else: + self.remove_style_class("active") + self.set_sensitive(mode is not None) + if mode is None: + self.set_tooltip_text("darkman is not running") + else: + self.set_tooltip_text(f"Switch to {'light' if dark else 'dark'} mode") diff --git a/sims/services/darkman.py b/sims/services/darkman.py new file mode 100644 index 0000000..1ff7438 --- /dev/null +++ b/sims/services/darkman.py @@ -0,0 +1,171 @@ +"""darkman control service. + +Talks to darkman's control socket (`$XDG_RUNTIME_DIR/darkman/control.sock`, +a newline-delimited text protocol: `get`, `set light|dark`, `toggle`, +`watch`) so sims can show and switch the current light/dark mode. + +A long-lived `watch` connection tracks the mode; darkman prints the current +mode immediately and then every change. Switching opens a short-lived +connection and sends `set `. If darkman is not running (yet), or +restarts, the watch connection is retried in the background. + +This only *controls* darkman. Re-theming sims itself still happens through +the portal color-scheme (see `sims.services.theme`), which darkman updates +as part of every switch. +""" + +import os + +from fabric.core.service import Service, Signal +from gi.repository import Gio, GLib +from loguru import logger + +from sims.config import DARKMAN + +MODES = ("light", "dark") + +_RETRY_MIN_S = 2 +_RETRY_MAX_S = 30 + +_service: "DarkmanService | None" = None + + +def _default_socket_path() -> str: + runtime_dir = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + return os.path.join(runtime_dir, "darkman", "control.sock") + + +class DarkmanService(Service): + @Signal + def changed(self, mode: object) -> None: + """Emitted with the new mode ("light", "dark"), or None while darkman + is unreachable.""" + + def __init__(self, socket_path: str | None = None, **kwargs): + super().__init__(**kwargs) + self._socket_path = os.path.expandvars( + os.path.expanduser(socket_path or _default_socket_path()) + ) + self._mode: str | None = None + self._client = Gio.SocketClient.new() + self._watch_conn: Gio.SocketConnection | None = None + self._watch_stream: Gio.DataInputStream | None = None + self._retry_s = _RETRY_MIN_S + self._retry_source: int | None = None + self._connect_watch() + + @property + def mode(self) -> str | None: + return self._mode + + @property + def available(self) -> bool: + return self._mode is not None + + # -- control --------------------------------------------------------- + + def set_mode(self, mode: str) -> None: + if mode not in MODES: + raise ValueError(f"invalid darkman mode: {mode!r}") + self._send(f"set {mode}") + + def toggle(self) -> None: + if self._mode is None: + logger.warning("[Darkman] not connected, cannot toggle mode") + return + self.set_mode("light" if self._mode == "dark" else "dark") + + def _send(self, command: str) -> None: + self._client.connect_async( + Gio.UnixSocketAddress.new(self._socket_path), + None, + self._on_command_connected, + command, + ) + + def _on_command_connected(self, client: Gio.SocketClient, result, command: str): + try: + conn = client.connect_finish(result) + except GLib.Error as e: + logger.warning(f"[Darkman] could not send {command!r}: {e.message}") + return + try: + # `set` gets no reply; a few bytes into a local socket never block. + conn.get_output_stream().write_all(f"{command}\n".encode(), None) + except GLib.Error as e: + logger.warning(f"[Darkman] could not send {command!r}: {e.message}") + finally: + conn.close(None) + + # -- watch ----------------------------------------------------------- + + def _connect_watch(self) -> bool: + self._retry_source = None + self._client.connect_async( + Gio.UnixSocketAddress.new(self._socket_path), + None, + self._on_watch_connected, + ) + return GLib.SOURCE_REMOVE + + def _on_watch_connected(self, client: Gio.SocketClient, result): + try: + conn = client.connect_finish(result) + conn.get_output_stream().write_all(b"watch\n", None) + except GLib.Error as e: + logger.debug(f"[Darkman] socket {self._socket_path} unavailable: {e.message}") + self._schedule_retry() + return + logger.info(f"[Darkman] watching {self._socket_path}") + self._retry_s = _RETRY_MIN_S + self._watch_conn = conn + self._watch_stream = Gio.DataInputStream.new(conn.get_input_stream()) + self._read_next() + + def _read_next(self): + assert self._watch_stream is not None + self._watch_stream.read_line_async( + GLib.PRIORITY_DEFAULT, None, self._on_line + ) + + def _on_line(self, stream: Gio.DataInputStream, result): + try: + line, _length = stream.read_line_finish_utf8(result) + except GLib.Error as e: + logger.warning(f"[Darkman] watch connection failed: {e.message}") + line = None + if line is None: + # EOF: darkman stopped or restarted. + self._drop_watch() + self._schedule_retry() + return + mode = line.strip() + self._set_mode(mode if mode in MODES else None) + self._read_next() + + def _drop_watch(self): + if self._watch_conn is not None: + self._watch_conn.close(None) + self._watch_conn = None + self._watch_stream = None + self._set_mode(None) + + def _schedule_retry(self): + if self._retry_source is not None: + return + self._retry_source = GLib.timeout_add_seconds(self._retry_s, self._connect_watch) + self._retry_s = min(self._retry_s * 2, _RETRY_MAX_S) + + def _set_mode(self, mode: str | None): + if mode == self._mode: + return + logger.info(f"[Darkman] mode={mode}") + self._mode = mode + self.changed(mode) + + +def get_darkman_service() -> DarkmanService: + global _service + if _service is None: + _service = DarkmanService(socket_path=DARKMAN.get("socket_path")) + return _service diff --git a/sims/styles/vinyl.css b/sims/styles/vinyl.css index 797d3ad..f984b37 100644 --- a/sims/styles/vinyl.css +++ b/sims/styles/vinyl.css @@ -27,3 +27,35 @@ #vinyl-button.active #vinyl-icon { color: var(--background); } + +/* Theme (darkman light/dark) toggle -- same look as the vinyl button */ +#theme-toggle-button { + background-color: var(--module-bg); + padding: 4px 8px; + border-radius: 12px; + transition: background-color 0.2s ease; +} + +#theme-toggle-button:hover { + background-color: var(--light-bg); +} + +#theme-toggle-button.active { + background-color: var(--pink); +} + +#theme-toggle-button.active:hover { + background-color: var(--turquoise); +} + +#theme-toggle-button:disabled { + opacity: 0.4; +} + +#theme-toggle-icon { + color: var(--foreground); +} + +#theme-toggle-button.active #theme-toggle-icon { + color: var(--background); +}