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:
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user