feat: manual darkman switching

This commit is contained in:
2026-10-05 12:09:28 +02:00
parent fc4fa21a0f
commit 4274971337
9 changed files with 364 additions and 22 deletions
+18
View File
@@ -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;
+19
View File
@@ -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)
+1
View File
@@ -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})
+25 -1
View File
@@ -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)
+32 -21
View File
@@ -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:
+27
View File
@@ -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"]};
+39
View File
@@ -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")
+171
View File
@@ -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 <mode>`. 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
+32
View File
@@ -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);
}