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:
56
README.md
56
README.md
@@ -49,26 +49,44 @@ uv run cursor-flasher stop
|
||||
Optional config file at `~/.cursor-flasher/config.yaml`:
|
||||
|
||||
```yaml
|
||||
running: # approval pulse (continuous until you interact)
|
||||
color: "#FF9500" # border color (hex)
|
||||
width: 4 # border thickness in pixels
|
||||
opacity: 0.85 # max border opacity
|
||||
pulse_speed: 1.5 # pulse cycle speed in seconds
|
||||
sound: "Glass" # macOS system sound ("" to disable)
|
||||
volume: 0.5 # 0.0 to 1.0
|
||||
# sounds: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
|
||||
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
|
||||
theme: "auto" # "dark", "light", or "auto" (follows macOS appearance)
|
||||
|
||||
completed: # agent stop flash (brief fade-in/out)
|
||||
color: "#00FF00" # different color for completion
|
||||
width: 4
|
||||
opacity: 0.85
|
||||
duration: 1.5 # flash duration in seconds
|
||||
sound: "" # no sound by default (Cursor plays its own)
|
||||
volume: 0.0
|
||||
dark: # styles used when OS is in dark mode
|
||||
running: # approval pulse (continuous until you interact)
|
||||
color: "#FF9500" # border color (hex)
|
||||
width: 4 # border thickness in pixels
|
||||
opacity: 0.85 # max border opacity
|
||||
pulse_speed: 1.5 # pulse cycle speed in seconds
|
||||
sound: "Glass" # macOS system sound ("" to disable)
|
||||
volume: 0.5 # 0.0 to 1.0
|
||||
# sounds: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
|
||||
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
|
||||
completed: # agent stop flash (brief fade-in/out)
|
||||
color: "#00FF00" # different color for completion
|
||||
width: 4
|
||||
opacity: 0.85
|
||||
duration: 1.5 # flash duration in seconds
|
||||
sound: "" # no sound by default (Cursor plays its own)
|
||||
volume: 0.0
|
||||
|
||||
light: # styles used when OS is in light mode
|
||||
running:
|
||||
color: "#3B82F6"
|
||||
width: 4
|
||||
opacity: 0.9
|
||||
pulse_speed: 1.5
|
||||
sound: "Glass"
|
||||
volume: 0.5
|
||||
completed:
|
||||
color: "#22C55E"
|
||||
width: 4
|
||||
opacity: 0.9
|
||||
duration: 1.5
|
||||
sound: ""
|
||||
volume: 0.0
|
||||
|
||||
flash:
|
||||
mode: "screen" # "window", "screen", or "allscreens"
|
||||
mode: "screen" # "window", "screen", or "allscreens"
|
||||
|
||||
# Tools that trigger the pulse + sound (approval mode).
|
||||
# Others are silently ignored (e.g., Read, Grep, Glob, Task).
|
||||
@@ -78,11 +96,11 @@ approval_tools:
|
||||
- Delete
|
||||
|
||||
general:
|
||||
approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals)
|
||||
approval_delay: 2.5 # seconds to wait before pulsing (filters auto-approvals)
|
||||
cooldown: 2.0 # minimum seconds between flashes
|
||||
```
|
||||
|
||||
Each mode (`running` and `completed`) has its own color, border style, and sound settings. Set `sound: ""` to disable sound for a particular mode.
|
||||
Styles are organized under `dark` and `light` theme sections, each containing `running` (approval pulse) and `completed` (stop flash) modes with their own color, border, and sound settings. The `theme` option controls which styles are active: set `"auto"` to follow macOS appearance in real-time, or force `"dark"` / `"light"`. Set `sound: ""` to disable sound for a particular mode.
|
||||
|
||||
## Uninstall
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ STYLE_FIELDS = {
|
||||
"volume": float,
|
||||
}
|
||||
|
||||
VALID_THEMES = {"dark", "light", "auto"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StyleConfig:
|
||||
@@ -36,10 +38,19 @@ def _default_completed() -> StyleConfig:
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
class ThemeStyles:
|
||||
"""Running and completed styles for a single theme (dark or light)."""
|
||||
|
||||
running: StyleConfig = field(default_factory=_default_running)
|
||||
completed: StyleConfig = field(default_factory=_default_completed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
dark: ThemeStyles = field(default_factory=ThemeStyles)
|
||||
light: ThemeStyles = field(default_factory=ThemeStyles)
|
||||
|
||||
theme: str = "auto"
|
||||
flash_mode: str = "screen"
|
||||
|
||||
approval_tools: list[str] = field(
|
||||
@@ -49,6 +60,16 @@ class Config:
|
||||
approval_delay: float = 2.5
|
||||
cooldown: float = 2.0
|
||||
|
||||
def active_styles(self, system_appearance: str) -> ThemeStyles:
|
||||
"""Return the ThemeStyles matching the current theme setting.
|
||||
|
||||
Args:
|
||||
system_appearance: "dark" or "light" as detected from the OS.
|
||||
"""
|
||||
if self.theme == "auto":
|
||||
return self.dark if system_appearance == "dark" else self.light
|
||||
return self.dark if self.theme == "dark" else self.light
|
||||
|
||||
|
||||
GENERAL_FIELD_MAP: dict[str, str] = {
|
||||
"approval_delay": "approval_delay",
|
||||
@@ -77,6 +98,21 @@ def _parse_style(raw_section: dict, defaults: StyleConfig) -> StyleConfig:
|
||||
)
|
||||
|
||||
|
||||
def _parse_theme_styles(raw_section: dict) -> ThemeStyles:
|
||||
"""Build a ThemeStyles from a YAML theme section (dark or light)."""
|
||||
kwargs: dict[str, StyleConfig] = {}
|
||||
|
||||
running_raw = raw_section.get("running")
|
||||
if isinstance(running_raw, dict):
|
||||
kwargs["running"] = _parse_style(running_raw, _default_running())
|
||||
|
||||
completed_raw = raw_section.get("completed")
|
||||
if isinstance(completed_raw, dict):
|
||||
kwargs["completed"] = _parse_style(completed_raw, _default_completed())
|
||||
|
||||
return ThemeStyles(**kwargs)
|
||||
|
||||
|
||||
def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
||||
"""Load config from YAML, falling back to defaults for missing values."""
|
||||
if not path.exists():
|
||||
@@ -90,13 +126,17 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
|
||||
|
||||
config_kwargs: dict[str, Any] = {}
|
||||
|
||||
running_raw = raw.get("running")
|
||||
if isinstance(running_raw, dict):
|
||||
config_kwargs["running"] = _parse_style(running_raw, _default_running())
|
||||
theme = raw.get("theme")
|
||||
if isinstance(theme, str) and theme in VALID_THEMES:
|
||||
config_kwargs["theme"] = theme
|
||||
|
||||
completed_raw = raw.get("completed")
|
||||
if isinstance(completed_raw, dict):
|
||||
config_kwargs["completed"] = _parse_style(completed_raw, _default_completed())
|
||||
dark_raw = raw.get("dark")
|
||||
if isinstance(dark_raw, dict):
|
||||
config_kwargs["dark"] = _parse_theme_styles(dark_raw)
|
||||
|
||||
light_raw = raw.get("light")
|
||||
if isinstance(light_raw, dict):
|
||||
config_kwargs["light"] = _parse_theme_styles(light_raw)
|
||||
|
||||
flash_raw = raw.get("flash")
|
||||
if isinstance(flash_raw, dict) and "mode" in flash_raw:
|
||||
|
||||
@@ -33,6 +33,13 @@ SOCKET_PATH = os.path.join(SOCKET_DIR, "flasher.sock")
|
||||
INPUT_DISMISS_GRACE = 0.5
|
||||
|
||||
|
||||
def _get_system_appearance() -> str:
|
||||
"""Return "dark" or "light" based on the current macOS appearance."""
|
||||
app = NSApplication.sharedApplication()
|
||||
name = app.effectiveAppearance().name()
|
||||
return "dark" if "Dark" in name else "light"
|
||||
|
||||
|
||||
class _PendingApproval:
|
||||
"""An approval trigger waiting for the delay to expire before pulsing."""
|
||||
__slots__ = ("workspace", "tool", "timestamp")
|
||||
@@ -125,14 +132,15 @@ class FlasherDaemon:
|
||||
return
|
||||
|
||||
frames = self._resolve_frames(window["frame"])
|
||||
styles = self.config.active_styles(_get_system_appearance())
|
||||
logger.info(
|
||||
"Pulsing for approval (after %.1fs delay): tool=%s window=%s",
|
||||
elapsed, pending.tool, window["title"],
|
||||
)
|
||||
self.overlay.pulse(frames, self.config.running)
|
||||
self.overlay.pulse(frames, styles.running)
|
||||
self._pulse_started_at = time.monotonic()
|
||||
self._cursor_was_frontmost = is_cursor_frontmost()
|
||||
play_alert(self.config.running)
|
||||
play_alert(styles.running)
|
||||
self._last_flash = time.monotonic()
|
||||
|
||||
def _check_input_dismiss(self) -> None:
|
||||
@@ -252,10 +260,11 @@ class FlasherDaemon:
|
||||
if window is None:
|
||||
return
|
||||
|
||||
styles = self.config.active_styles(_get_system_appearance())
|
||||
frames = self._resolve_frames(window["frame"])
|
||||
logger.info("Flash for stop: window=%s", window["title"])
|
||||
self.overlay.flash(frames, self.config.completed)
|
||||
play_alert(self.config.completed)
|
||||
self.overlay.flash(frames, styles.completed)
|
||||
play_alert(styles.completed)
|
||||
self._last_flash = now
|
||||
|
||||
def _resolve_frames(self, window_frame: tuple) -> list[tuple]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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