feat: manual darkman switching
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,9 +134,9 @@ 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")
|
||||
|
||||
for label, widget in rows:
|
||||
row = Box(
|
||||
name="control-center-settings-row",
|
||||
orientation="h",
|
||||
@@ -134,14 +145,14 @@ class ControlCenter(Window):
|
||||
row.pack_start(
|
||||
Label(
|
||||
name="control-center-settings-label",
|
||||
label="Vinyl Passthrough",
|
||||
label=label,
|
||||
h_align="start",
|
||||
),
|
||||
True,
|
||||
True,
|
||||
0,
|
||||
)
|
||||
row.pack_end(self._vinyl_button, False, False, 0)
|
||||
row.pack_end(widget, False, False, 0)
|
||||
section.add(row)
|
||||
return section
|
||||
|
||||
|
||||
@@ -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"]};
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user