Add dark/light theme support with real-time OS appearance detection

Config now uses top-level dark/light sections (each with running/completed
styles) and a theme option ("dark", "light", "auto"). The daemon resolves
the active theme at flash time via NSApp.effectiveAppearance().

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 09:06:09 -04:00
parent 49c03e4b71
commit a3203e2970
5 changed files with 326 additions and 95 deletions

View File

@@ -1,23 +1,42 @@
from cursor_flasher.config import Config, StyleConfig, load_config
from cursor_flasher.config import (
Config,
StyleConfig,
ThemeStyles,
load_config,
)
class TestDefaultConfig:
def test_running_defaults(self):
def test_dark_running_defaults(self):
c = Config()
assert c.running.color == "#FF9500"
assert c.running.width == 4
assert c.running.duration == 1.5
assert c.running.opacity == 0.85
assert c.running.pulse_speed == 1.5
assert c.running.sound == "Glass"
assert c.running.volume == 0.5
assert c.dark.running.color == "#FF9500"
assert c.dark.running.width == 4
assert c.dark.running.duration == 1.5
assert c.dark.running.opacity == 0.85
assert c.dark.running.pulse_speed == 1.5
assert c.dark.running.sound == "Glass"
assert c.dark.running.volume == 0.5
def test_completed_defaults(self):
def test_dark_completed_defaults(self):
c = Config()
assert c.completed.color == "#FF9500"
assert c.completed.width == 4
assert c.completed.sound == ""
assert c.completed.volume == 0.0
assert c.dark.completed.color == "#FF9500"
assert c.dark.completed.width == 4
assert c.dark.completed.sound == ""
assert c.dark.completed.volume == 0.0
def test_light_running_defaults(self):
c = Config()
assert c.light.running.color == "#FF9500"
assert c.light.running.sound == "Glass"
def test_light_completed_defaults(self):
c = Config()
assert c.light.completed.sound == ""
assert c.light.completed.volume == 0.0
def test_theme_defaults_to_auto(self):
c = Config()
assert c.theme == "auto"
def test_has_approval_tools(self):
c = Config()
@@ -32,6 +51,41 @@ class TestDefaultConfig:
assert c.flash_mode == "screen"
class TestActiveStyles:
def test_auto_returns_dark_when_system_dark(self):
c = Config(theme="auto")
dark_styles = ThemeStyles(running=StyleConfig(color="#111111"))
light_styles = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
c.dark = dark_styles
c.light = light_styles
assert c.active_styles("dark").running.color == "#111111"
def test_auto_returns_light_when_system_light(self):
c = Config(theme="auto")
dark_styles = ThemeStyles(running=StyleConfig(color="#111111"))
light_styles = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
c.dark = dark_styles
c.light = light_styles
assert c.active_styles("light").running.color == "#EEEEEE"
def test_explicit_dark_ignores_system(self):
c = Config(theme="dark")
c.dark = ThemeStyles(running=StyleConfig(color="#111111"))
c.light = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
assert c.active_styles("light").running.color == "#111111"
def test_explicit_light_ignores_system(self):
c = Config(theme="light")
c.dark = ThemeStyles(running=StyleConfig(color="#111111"))
c.light = ThemeStyles(running=StyleConfig(color="#EEEEEE"))
assert c.active_styles("dark").running.color == "#EEEEEE"
def test_active_styles_includes_completed(self):
c = Config(theme="dark")
c.dark = ThemeStyles(completed=StyleConfig(color="#AA0000"))
assert c.active_styles("light").completed.color == "#AA0000"
class TestLoadConfig:
def test_missing_file_returns_defaults(self, tmp_path):
c = load_config(tmp_path / "nope.yaml")
@@ -43,26 +97,80 @@ class TestLoadConfig:
c = load_config(p)
assert c == Config()
def test_loads_running_overrides(self, tmp_path):
def test_loads_theme(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"running:\n color: '#00FF00'\n duration: 2.0\n sound: Ping\n"
)
p.write_text("theme: dark\n")
c = load_config(p)
assert c.running.color == "#00FF00"
assert c.running.duration == 2.0
assert c.running.sound == "Ping"
assert c.running.width == 4
assert c.theme == "dark"
def test_loads_completed_overrides(self, tmp_path):
def test_invalid_theme_uses_default(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text("theme: neon\n")
c = load_config(p)
assert c.theme == "auto"
def test_loads_dark_running_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"completed:\n color: '#0000FF'\n sound: Hero\n volume: 0.8\n"
"dark:\n"
" running:\n"
" color: '#00FF00'\n"
" duration: 2.0\n"
" sound: Ping\n"
)
c = load_config(p)
assert c.completed.color == "#0000FF"
assert c.completed.sound == "Hero"
assert c.completed.volume == 0.8
assert c.dark.running.color == "#00FF00"
assert c.dark.running.duration == 2.0
assert c.dark.running.sound == "Ping"
assert c.dark.running.width == 4
def test_loads_dark_completed_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"dark:\n"
" completed:\n"
" color: '#0000FF'\n"
" sound: Hero\n"
" volume: 0.8\n"
)
c = load_config(p)
assert c.dark.completed.color == "#0000FF"
assert c.dark.completed.sound == "Hero"
assert c.dark.completed.volume == 0.8
def test_loads_light_running_overrides(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"light:\n"
" running:\n"
" color: '#3B82F6'\n"
" opacity: 0.9\n"
)
c = load_config(p)
assert c.light.running.color == "#3B82F6"
assert c.light.running.opacity == 0.9
assert c.light.running.width == 4
def test_missing_dark_section_uses_defaults(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"light:\n"
" running:\n"
" color: '#FFFFFF'\n"
)
c = load_config(p)
assert c.dark == ThemeStyles()
def test_missing_running_within_theme_uses_defaults(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"dark:\n"
" completed:\n"
" color: '#FF0000'\n"
)
c = load_config(p)
assert c.dark.running == StyleConfig()
assert c.dark.completed.color == "#FF0000"
def test_loads_flash_mode(self, tmp_path):
p = tmp_path / "config.yaml"
@@ -86,16 +194,25 @@ class TestLoadConfig:
def test_full_config(self, tmp_path):
p = tmp_path / "config.yaml"
p.write_text(
"running:\n"
" color: '#FF0000'\n"
" width: 6\n"
" opacity: 0.9\n"
" pulse_speed: 2.0\n"
" sound: Glass\n"
" volume: 0.8\n"
"completed:\n"
" color: '#00FF00'\n"
" sound: ''\n"
"theme: light\n"
"dark:\n"
" running:\n"
" color: '#FF0000'\n"
" width: 6\n"
" opacity: 0.9\n"
" pulse_speed: 2.0\n"
" sound: Glass\n"
" volume: 0.8\n"
" completed:\n"
" color: '#00FF00'\n"
" sound: ''\n"
"light:\n"
" running:\n"
" color: '#3B82F6'\n"
" width: 3\n"
" completed:\n"
" color: '#22C55E'\n"
" duration: 2.0\n"
"flash:\n"
" mode: window\n"
"general:\n"
@@ -105,14 +222,19 @@ class TestLoadConfig:
" - Shell\n"
)
c = load_config(p)
assert c.running.color == "#FF0000"
assert c.running.width == 6
assert c.running.opacity == 0.9
assert c.running.pulse_speed == 2.0
assert c.running.sound == "Glass"
assert c.running.volume == 0.8
assert c.completed.color == "#00FF00"
assert c.completed.sound == ""
assert c.theme == "light"
assert c.dark.running.color == "#FF0000"
assert c.dark.running.width == 6
assert c.dark.running.opacity == 0.9
assert c.dark.running.pulse_speed == 2.0
assert c.dark.running.sound == "Glass"
assert c.dark.running.volume == 0.8
assert c.dark.completed.color == "#00FF00"
assert c.dark.completed.sound == ""
assert c.light.running.color == "#3B82F6"
assert c.light.running.width == 3
assert c.light.completed.color == "#22C55E"
assert c.light.completed.duration == 2.0
assert c.flash_mode == "window"
assert c.approval_delay == 1.0
assert c.cooldown == 3.0

View File

@@ -4,10 +4,13 @@ import time
from unittest.mock import patch, MagicMock
from cursor_flasher.config import Config, StyleConfig
from cursor_flasher.config import Config, StyleConfig, ThemeStyles
from cursor_flasher.daemon import FlasherDaemon
PATCH_APPEARANCE = "cursor_flasher.daemon._get_system_appearance"
class TestFlasherDaemon:
def _make_daemon(self, **config_overrides) -> FlasherDaemon:
config = Config(**config_overrides)
@@ -39,10 +42,11 @@ class TestFlasherDaemon:
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.running)
daemon.overlay.pulse.assert_called_once_with([screen], daemon.config.dark.running)
def test_postToolUse_cancels_pending(self):
"""Auto-approved tools: postToolUse arrives before delay, cancels the pulse."""
@@ -79,7 +83,8 @@ class TestFlasherDaemon:
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=((0, 0), (1920, 1080))), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once()
@@ -91,25 +96,27 @@ class TestFlasherDaemon:
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.screen_frame_for_window", return_value=screen), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.flash.assert_called_once_with([screen], daemon.config.completed)
daemon.overlay.flash.assert_called_once_with([screen], daemon.config.dark.completed)
def test_stop_flashes_window_frame_when_window_mode(self):
daemon = self._make_daemon(flash_mode="window")
window = {"title": "my-project", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="light"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
daemon.overlay.flash.assert_called_once_with(
[((0, 0), (800, 600))], daemon.config.completed
[((0, 0), (800, 600))], daemon.config.light.completed
)
def test_allscreens_mode_uses_all_screens(self):
@@ -124,10 +131,11 @@ class TestFlasherDaemon:
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.all_screen_frames", return_value=screens), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.running)
daemon.overlay.pulse.assert_called_once_with(screens, daemon.config.dark.running)
def test_stop_dismisses_active_pulse(self):
daemon = self._make_daemon()
@@ -309,7 +317,8 @@ class TestFlasherDaemon:
def test_running_style_sound_plays_on_approval(self):
"""Running style with sound configured plays on approval pulse."""
running = StyleConfig(sound="Glass", volume=0.5)
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", running=running)
dark = ThemeStyles(running=running)
daemon = self._make_daemon(approval_delay=0.0, flash_mode="window", dark=dark)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
@@ -318,7 +327,8 @@ class TestFlasherDaemon:
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert") as mock_alert:
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
mock_alert.assert_called_once_with(running)
@@ -326,11 +336,13 @@ class TestFlasherDaemon:
def test_completed_style_sound_plays_on_stop(self):
"""Completed style with sound configured plays on stop flash."""
completed = StyleConfig(sound="Ping", volume=0.7)
daemon = self._make_daemon(flash_mode="window", completed=completed)
dark = ThemeStyles(completed=completed)
daemon = self._make_daemon(flash_mode="window", dark=dark)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert") as mock_alert:
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
@@ -340,11 +352,13 @@ class TestFlasherDaemon:
def test_no_sound_when_style_sound_empty(self):
"""No sound plays when the style has sound="" (the completed default)."""
completed = StyleConfig(sound="", volume=0.0)
daemon = self._make_daemon(flash_mode="window", completed=completed)
dark = ThemeStyles(completed=completed)
daemon = self._make_daemon(flash_mode="window", dark=dark)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert") as mock_alert:
patch("cursor_flasher.daemon.play_alert") as mock_alert, \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
@@ -355,9 +369,9 @@ class TestFlasherDaemon:
"""Different colors for running vs completed are passed through."""
running = StyleConfig(color="#FF0000")
completed = StyleConfig(color="#00FF00")
dark = ThemeStyles(running=running, completed=completed)
daemon = self._make_daemon(
approval_delay=0.0, flash_mode="window",
running=running, completed=completed,
approval_delay=0.0, flash_mode="window", dark=dark,
)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
@@ -367,7 +381,8 @@ class TestFlasherDaemon:
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with(
@@ -378,7 +393,8 @@ class TestFlasherDaemon:
daemon._last_flash = 0
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.play_alert"):
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="dark"):
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "stop"}).encode()
)
@@ -386,3 +402,29 @@ class TestFlasherDaemon:
daemon.overlay.flash.assert_called_once_with(
[((0, 0), (800, 600))], completed
)
def test_theme_auto_uses_light_when_system_light(self):
"""Auto theme resolves to light styles when system is in light mode."""
dark_running = StyleConfig(color="#111111")
light_running = StyleConfig(color="#EEEEEE")
daemon = self._make_daemon(
approval_delay=0.0, flash_mode="window",
dark=ThemeStyles(running=dark_running),
light=ThemeStyles(running=light_running),
theme="auto",
)
window = {"title": "proj", "frame": ((0, 0), (800, 600))}
daemon._handle_message(
json.dumps({"workspace": "/path", "event": "preToolUse", "tool": "Shell"}).encode()
)
with patch("cursor_flasher.daemon.find_window_by_workspace", return_value=window), \
patch("cursor_flasher.daemon.is_cursor_frontmost", return_value=False), \
patch("cursor_flasher.daemon.play_alert"), \
patch(PATCH_APPEARANCE, return_value="light"):
daemon._check_pending()
daemon.overlay.pulse.assert_called_once_with(
[((0, 0), (800, 600))], light_running
)