From 796f30125012eb98cef6a0e894e1327b5c59489f Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Fri, 10 Apr 2026 20:54:17 +0200 Subject: [PATCH] docs: add tray icon implementation plan --- .../superpowers/plans/2026-04-10-tray-icon.md | 1465 +++++++++++++++++ 1 file changed, 1465 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-10-tray-icon.md diff --git a/docs/superpowers/plans/2026-04-10-tray-icon.md b/docs/superpowers/plans/2026-04-10-tray-icon.md new file mode 100644 index 0000000..6a46c34 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-tray-icon.md @@ -0,0 +1,1465 @@ +# Tray-Icon mit Einstellungs-Dialog — Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** whisper-local erhält ein Windows-Tray-Icon mit 3 Zustandsfarben (Warten/Aufnahme/Transkription), Rechtsklick-Menü (Einstellungen/Beenden) und einem Einstellungs-Dialog für Hotkey und Mikrofon. + +**Architecture:** Neues Package `whisper_local/tray/` mit Factory-Funktion `create_tray()` analog zu `create_listener()` und `create_inserter()`. Auf Linux gibt `create_tray()` ein No-Op-Stub zurück. Tray-Thread und tkinter-Dialog laufen in dedizierten Threads; Kommunikation mit dem asyncio-Loop via `call_soon_threadsafe` / `run_coroutine_threadsafe`. + +**Tech Stack:** pystray, Pillow, tkinter, sv-ttk, darkdetect, ctypes (Win32), sounddevice (bereits vorhanden), pynput (bereits vorhanden) + +--- + +## Dateiübersicht + +| Aktion | Datei | Verantwortung | +|---|---|---| +| Ändern | `whisper_local/config.py` | `microphone`-Feld + `save_config()` | +| Ändern | `whisper_local/recorder.py` | `device`-Parameter für sounddevice | +| Ändern | `whisper_local/hotkey/__init__.py` | `stop()` im Protocol | +| Ändern | `whisper_local/hotkey/_pynput.py` | `stop()` implementieren | +| Ändern | `whisper_local/hotkey/_evdev.py` | `stop()` Stub für Protocol-Konformität | +| Ändern | `pyproject.toml` | Neue Windows-Abhängigkeiten | +| Erstellen | `whisper_local/tray/__init__.py` | `create_tray()`, `AppState` re-export | +| Erstellen | `whisper_local/tray/_icon.py` | Pillow-Icon-Generierung | +| Erstellen | `whisper_local/tray/_tray.py` | `AppState`, `NoOpTray`, `Win32TrayApp` | +| Erstellen | `whisper_local/tray/_theme.py` | System-Theme-Erkennung | +| Erstellen | `whisper_local/tray/_settings.py` | `SettingsDialog`, Konflikt-Erkennung | +| Ändern | `whisper_local/__main__.py` | Tray in `App` integrieren | +| Ändern | `tests/test_config.py` | Tests für `microphone` + `save_config` | +| Ändern | `tests/test_recorder.py` | Test für `device`-Parameter | +| Ändern | `tests/test_hotkey.py` | Tests für `stop()` | +| Erstellen | `tests/test_tray.py` | Alle Tray-Tests | +| Ändern | `tests/test_main.py` | `create_tray` mocken, Tray-Callbacks testen | + +--- + +## Task 1: Config — `microphone`-Feld + `save_config()` + +**Files:** +- Modify: `whisper_local/config.py` +- Modify: `tests/test_config.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +Ans Ende von `tests/test_config.py` anfügen: + +```python +class TestMicrophoneConfig: + def test_default_microphone_is_empty(self): + config = Config() + assert config.microphone == "" + + def test_load_device_from_toml(self, tmp_path): + config_file = tmp_path / "config.toml" + config_file.write_text('[audio]\ndevice = "Headset Mic"\n') + config = load_config(config_file) + assert config.microphone == "Headset Mic" + + +class TestSaveConfig: + def test_save_and_reload(self, tmp_path): + from whisper_local.config import save_config + path = tmp_path / "config.toml" + config = Config(hotkey="KEY_F8", microphone="USB Mic") + save_config(config, path) + loaded = load_config(path) + assert loaded.hotkey == "KEY_F8" + assert loaded.microphone == "USB Mic" + + def test_save_creates_parent_dirs(self, tmp_path): + from whisper_local.config import save_config + path = tmp_path / "subdir" / "config.toml" + save_config(Config(), path) + assert path.exists() +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_config.py::TestMicrophoneConfig tests/test_config.py::TestSaveConfig -v +``` + +Erwartet: FAIL — `Config` hat kein `microphone`, `save_config` existiert nicht. + +- [ ] **Schritt 3: Implementierung** + +`whisper_local/config.py` — `Config`-Dataclass und `load_config` und neue Funktion `save_config`: + +```python +@dataclass +class Config: + hotkey: str = "KEY_F12" + whisper_model: str = "small" + language: str = "de" + compute_type: str = "int8" + sample_rate: int = 16000 + channels: int = 1 + min_duration: float = 0.5 + microphone: str = "" +``` + +In `load_config`, nach dem `audio_section`-Block ergänzen: + +```python + if "device" in audio_section: + config.microphone = audio_section["device"] +``` + +Neue Funktion am Ende der Datei: + +```python +def save_config(config: Config, path: Path = DEFAULT_CONFIG_PATH) -> None: + """Schreibt Config als TOML-Datei. Erstellt Verzeichnisse bei Bedarf.""" + path.parent.mkdir(parents=True, exist_ok=True) + content = ( + f'[hotkey]\nkey = "{config.hotkey}"\n\n' + f'[whisper]\nmodel = "{config.whisper_model}"\n' + f'language = "{config.language}"\n' + f'compute_type = "{config.compute_type}"\n\n' + f'[audio]\nsample_rate = {config.sample_rate}\n' + f'channels = {config.channels}\n' + f'min_duration = {config.min_duration}\n' + f'device = "{config.microphone}"\n' + ) + path.write_text(content, encoding="utf-8") +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_config.py -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/config.py tests/test_config.py +git commit -m "feat: add microphone field and save_config to Config" +``` + +--- + +## Task 2: Recorder — `device`-Parameter + +**Files:** +- Modify: `whisper_local/recorder.py` +- Modify: `tests/test_recorder.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +Ans Ende von `tests/test_recorder.py` anfügen: + +```python + def test_device_passed_to_inputstream(self): + recorder = Recorder(sample_rate=16000, channels=1, min_duration=0.0, device="USB Mic") + with patch("sounddevice.InputStream") as mock_cls: + mock_cls.return_value = MagicMock() + recorder.start() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["device"] == "USB Mic" + recorder.stop() + + def test_default_device_is_none(self): + recorder = Recorder() + assert recorder.device is None +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_recorder.py::TestRecorder::test_device_passed_to_inputstream tests/test_recorder.py::TestRecorder::test_default_device_is_none -v +``` + +Erwartet: FAIL — `Recorder` kennt kein `device`. + +- [ ] **Schritt 3: Implementierung** + +`whisper_local/recorder.py`: + +```python +class Recorder: + def __init__( + self, + sample_rate: int = 16000, + channels: int = 1, + min_duration: float = 0.5, + device: str | None = None, + ): + self.sample_rate = sample_rate + self.channels = channels + self.min_duration = min_duration + self.device = device + self.is_recording = False + self._chunks: list[np.ndarray] = [] + self._stream: sd.InputStream | None = None +``` + +In `start()` den `InputStream`-Aufruf um `device=self.device` ergänzen: + +```python + self._stream = sd.InputStream( + samplerate=self.sample_rate, + channels=self.channels, + dtype=np.float32, + callback=self._audio_callback, + device=self.device, + ) +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_recorder.py -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/recorder.py tests/test_recorder.py +git commit -m "feat: add optional device parameter to Recorder" +``` + +--- + +## Task 3: HotkeyListener — `stop()`-Methode + +**Files:** +- Modify: `whisper_local/hotkey/__init__.py` +- Modify: `whisper_local/hotkey/_pynput.py` +- Modify: `whisper_local/hotkey/_evdev.py` +- Modify: `tests/test_hotkey.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +Ans Ende von `tests/test_hotkey.py` anfügen: + +```python +class TestPynputHotkeyListenerStop: + @pytest.mark.asyncio + async def test_stop_ends_listen(self): + from whisper_local.hotkey._pynput import PynputHotkeyListener + from unittest.mock import patch, MagicMock + listener = PynputHotkeyListener("KEY_F12") + + with patch("whisper_local.hotkey._pynput.Listener") as mock_listener_cls: + mock_listener_cls.return_value = MagicMock() + listen_task = asyncio.create_task(listener.listen()) + await asyncio.sleep(0) # Loop einen Schritt weiter + listener.stop() + await asyncio.wait_for(listen_task, timeout=1.0) +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_hotkey.py::TestPynputHotkeyListenerStop -v +``` + +Erwartet: FAIL — `PynputHotkeyListener` hat keine `stop()`-Methode. + +- [ ] **Schritt 3: Protocol aktualisieren** + +`whisper_local/hotkey/__init__.py`: + +```python +@runtime_checkable +class HotkeyListener(Protocol): + on_press: AsyncCallback | None + on_release: AsyncCallback | None + + async def listen(self) -> None: ... + def stop(self) -> None: ... +``` + +- [ ] **Schritt 4: PynputHotkeyListener aktualisieren** + +`whisper_local/hotkey/_pynput.py` — `__init__` und `listen` und neue Methode `stop`: + +```python +class PynputHotkeyListener: + def __init__(self, key_name: str = "KEY_F12"): + self.key_name = key_name + self._target_key = _evdev_to_pynput_key(key_name) + self.on_press: AsyncCallback | None = None + self.on_release: AsyncCallback | None = None + self._loop: asyncio.AbstractEventLoop | None = None + self._pressed = False + self._stop_event: asyncio.Event | None = None + + def stop(self) -> None: + """Signalisiert dem listen()-Loop zu beenden.""" + if self._loop is not None and self._stop_event is not None: + self._loop.call_soon_threadsafe(self._stop_event.set) + + async def listen(self) -> None: + self._loop = asyncio.get_running_loop() + self._stop_event = asyncio.Event() + + listener = Listener(on_press=self._on_press, on_release=self._on_release) + listener.start() + logger.info("Lausche auf %s via pynput", self.key_name) + + try: + await self._stop_event.wait() + finally: + listener.stop() +``` + +- [ ] **Schritt 5: EvdevHotkeyListener Stub hinzufügen** + +`whisper_local/hotkey/_evdev.py` — `stop()`-Stub für Protocol-Konformität: + +```python + def stop(self) -> None: + """Stub — evdev-Listener läuft bis zum Prozessende.""" + pass +``` + +- [ ] **Schritt 6: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_hotkey.py -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 7: Commit** + +```bash +git add whisper_local/hotkey/__init__.py whisper_local/hotkey/_pynput.py whisper_local/hotkey/_evdev.py tests/test_hotkey.py +git commit -m "feat: add stop() method to HotkeyListener protocol and PynputHotkeyListener" +``` + +--- + +## Task 4: Abhängigkeiten + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Schritt 1: `pyproject.toml` aktualisieren** + +Im `dependencies`-Array ergänzen (nach `pywin32`-Zeile): + +```toml +dependencies = [ + "faster-whisper>=1.1.0", + "sounddevice>=0.5.0", + "numpy>=2.0.0", + "evdev>=1.7.0; sys_platform == 'linux'", + "pynput>=1.7.0; sys_platform == 'win32'", + "pywin32>=306; sys_platform == 'win32'", + "pystray>=0.19.0; sys_platform == 'win32'", + "Pillow>=10.0.0; sys_platform == 'win32'", + "sv-ttk>=2.6.0; sys_platform == 'win32'", + "darkdetect>=0.8.0; sys_platform == 'win32'", +] +``` + +- [ ] **Schritt 2: Lock-Datei aktualisieren** + +``` +uv lock +``` + +Erwartet: `uv.lock` wird aktualisiert, kein Fehler. + +- [ ] **Schritt 3: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build: add pystray, Pillow, sv-ttk, darkdetect as Windows dependencies" +``` + +--- + +## Task 5: `_icon.py` — Icon-Generierung + +**Files:** +- Create: `whisper_local/tray/__init__.py` (leer) +- Create: `whisper_local/tray/_icon.py` +- Create: `tests/test_tray.py` + +- [ ] **Schritt 1: Leeres Package anlegen** + +`whisper_local/tray/__init__.py` mit leerem Inhalt erstellen (wird in Task 10 befüllt). + +- [ ] **Schritt 2: Fehlschlagenden Test schreiben** + +`tests/test_tray.py` erstellen: + +```python +import sys +import pytest + + +@pytest.mark.skipif(sys.platform != "win32", reason="Tray nur auf Windows") +class TestCreateIcon: + def test_returns_image_for_each_state(self): + from PIL import Image + from whisper_local.tray._tray import AppState + from whisper_local.tray._icon import create_icon + + for state in AppState: + img = create_icon(state) + assert isinstance(img, Image.Image) + assert img.size == (64, 64) + assert img.mode == "RGBA" + + def test_different_states_have_different_colors(self): + from whisper_local.tray._tray import AppState + from whisper_local.tray._icon import create_icon + + waiting = create_icon(AppState.WAITING) + recording = create_icon(AppState.RECORDING) + assert waiting.tobytes() != recording.tobytes() +``` + +- [ ] **Schritt 3: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_tray.py::TestCreateIcon -v +``` + +Erwartet: FAIL — Module nicht vorhanden. + +- [ ] **Schritt 4: `_tray.py` — AppState-Enum anlegen** (wird in Task 6 vervollständigt) + +`whisper_local/tray/_tray.py` erstellen — nur den Enum, damit `_icon.py` importieren kann: + +```python +"""Tray-App und App-Zustände für whisper-local (Windows).""" + +import enum + + +class AppState(enum.Enum): + WAITING = "waiting" + RECORDING = "recording" + TRANSCRIBING = "transcribing" +``` + +- [ ] **Schritt 5: `_icon.py` implementieren** + +`whisper_local/tray/_icon.py` erstellen: + +```python +"""Programmatische Icon-Generierung via Pillow.""" + +from PIL import Image, ImageDraw + +from whisper_local.tray._tray import AppState + +_STATE_COLORS: dict[AppState, tuple[int, int, int]] = { + AppState.WAITING: (150, 150, 150), + AppState.RECORDING: (220, 50, 50), + AppState.TRANSCRIBING: (220, 180, 0), +} + + +def create_icon(state: AppState, size: int = 64) -> Image.Image: + """Erzeugt ein Mikrofon-Icon in der Farbe des übergebenen Zustands.""" + color = _STATE_COLORS[state] + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + cx = size // 2 + bw = max(4, size // 6) # Hälfte der Körperbreite + top = size // 8 + mid = size // 2 + + # Mikrofon-Körper (abgerundetes Rechteck) + draw.rounded_rectangle( + [cx - bw, top, cx + bw, mid + bw], + radius=bw, + fill=color, + ) + + # Bogen (Stativ-Bogen) + arc_r = size // 4 + arc_top = mid + lw = max(2, size // 20) + draw.arc( + [cx - arc_r, arc_top, cx + arc_r, arc_top + arc_r], + start=0, end=180, fill=color, width=lw, + ) + + # Stiel + pole_top = arc_top + arc_r // 2 + pole_bot = size - size // 8 + draw.line([cx, pole_top, cx, pole_bot], fill=color, width=lw) + + # Sockel + base = size // 5 + draw.line([cx - base, pole_bot, cx + base, pole_bot], fill=color, width=lw) + + return img +``` + +- [ ] **Schritt 6: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_tray.py::TestCreateIcon -v +``` + +Erwartet: alle PASS (oder skip auf Linux). + +- [ ] **Schritt 7: Commit** + +```bash +git add whisper_local/tray/__init__.py whisper_local/tray/_tray.py whisper_local/tray/_icon.py tests/test_tray.py +git commit -m "feat: add AppState enum and programmatic tray icon generation" +``` + +--- + +## Task 6: `_tray.py` — `Win32TrayApp` + `NoOpTray` + +**Files:** +- Modify: `whisper_local/tray/_tray.py` +- Modify: `tests/test_tray.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `tests/test_tray.py` ergänzen: + +```python +@pytest.mark.skipif(sys.platform != "win32", reason="Tray nur auf Windows") +class TestWin32TrayApp: + def test_set_state_updates_icon(self): + from unittest.mock import MagicMock, patch + from whisper_local.tray._tray import AppState, Win32TrayApp + + app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock()) + + mock_icon = MagicMock() + app._icon = mock_icon + + app.set_state(AppState.RECORDING) + + assert mock_icon.icon is not None + + def test_set_state_before_start_is_safe(self): + from unittest.mock import MagicMock + from whisper_local.tray._tray import AppState, Win32TrayApp + + app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock()) + app.set_state(AppState.WAITING) # kein Fehler, _icon ist None + + +class TestNoOpTray: + def test_start_does_nothing(self): + from whisper_local.tray._tray import AppState, NoOpTray + tray = NoOpTray() + tray.start() # kein Fehler + + def test_set_state_does_nothing(self): + from whisper_local.tray._tray import AppState, NoOpTray + tray = NoOpTray() + tray.set_state(AppState.RECORDING) # kein Fehler +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_tray.py::TestWin32TrayApp tests/test_tray.py::TestNoOpTray -v +``` + +Erwartet: FAIL — `Win32TrayApp` und `NoOpTray` nicht vorhanden. + +- [ ] **Schritt 3: `_tray.py` vervollständigen** + +`whisper_local/tray/_tray.py` komplett ersetzen: + +```python +"""Tray-App und App-Zustände für whisper-local (Windows).""" + +import enum +import sys +import threading +from typing import Callable + + +class AppState(enum.Enum): + WAITING = "waiting" + RECORDING = "recording" + TRANSCRIBING = "transcribing" + + +class Win32TrayApp: + """Tray-Icon via pystray für Windows.""" + + def __init__(self, on_settings: Callable[[], None], on_quit: Callable[[], None]): + self._on_settings = on_settings + self._on_quit = on_quit + self._icon = None + + def start(self) -> None: + """Startet pystray in einem Daemon-Thread.""" + import pystray + from whisper_local.tray._icon import create_icon + + menu = pystray.Menu( + pystray.MenuItem("Einstellungen", self._menu_settings), + pystray.MenuItem("Beenden", self._menu_quit), + ) + self._icon = pystray.Icon( + "whisper-local", + create_icon(AppState.WAITING), + "whisper-local", + menu, + ) + thread = threading.Thread(target=self._icon.run, daemon=True) + thread.start() + + def set_state(self, state: AppState) -> None: + """Tauscht das Icon aus (thread-sicher).""" + if self._icon is not None: + from whisper_local.tray._icon import create_icon + self._icon.icon = create_icon(state) + + def _menu_settings(self, icon, item) -> None: + self._on_settings() + + def _menu_quit(self, icon, item) -> None: + if self._icon is not None: + self._icon.stop() + self._on_quit() + + +class NoOpTray: + """Platzhalter-Implementierung für nicht-Windows-Plattformen.""" + + def start(self) -> None: + pass + + def set_state(self, state: AppState) -> None: + pass +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_tray.py -v +``` + +Erwartet: alle PASS (Windows), Skips auf Linux. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/tray/_tray.py tests/test_tray.py +git commit -m "feat: add Win32TrayApp and NoOpTray with state management" +``` + +--- + +## Task 7: `_theme.py` — System-Theme-Erkennung + +**Files:** +- Create: `whisper_local/tray/_theme.py` +- Modify: `tests/test_tray.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `tests/test_tray.py` ergänzen: + +```python +@pytest.mark.skipif(sys.platform != "win32", reason="Theme nur auf Windows") +class TestApplySystemTheme: + def test_applies_light_theme(self): + import tkinter as tk + from unittest.mock import patch, MagicMock + from whisper_local.tray._theme import apply_system_theme + + root = tk.Tk() + root.withdraw() + try: + with patch("darkdetect.theme", return_value="Light"), \ + patch("sv_ttk.set_theme") as mock_set: + apply_system_theme(root) + mock_set.assert_called_once_with("light") + finally: + root.destroy() + + def test_applies_dark_theme(self): + import tkinter as tk + from unittest.mock import patch + from whisper_local.tray._theme import apply_system_theme + + root = tk.Tk() + root.withdraw() + try: + with patch("darkdetect.theme", return_value="Dark"), \ + patch("sv_ttk.set_theme") as mock_set: + apply_system_theme(root) + mock_set.assert_called_once_with("dark") + finally: + root.destroy() + + def test_falls_back_to_light_when_none(self): + import tkinter as tk + from unittest.mock import patch + from whisper_local.tray._theme import apply_system_theme + + root = tk.Tk() + root.withdraw() + try: + with patch("darkdetect.theme", return_value=None), \ + patch("sv_ttk.set_theme") as mock_set: + apply_system_theme(root) + mock_set.assert_called_once_with("light") + finally: + root.destroy() +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_tray.py::TestApplySystemTheme -v +``` + +Erwartet: FAIL — `_theme.py` nicht vorhanden. + +- [ ] **Schritt 3: `_theme.py` implementieren** + +`whisper_local/tray/_theme.py` erstellen: + +```python +"""System-Theme-Erkennung und sv-ttk-Anwendung.""" + +import tkinter as tk + + +def apply_system_theme(root: tk.Tk) -> None: + """Setzt sv-ttk-Theme passend zum Windows-System-Theme (Light/Dark).""" + import darkdetect + import sv_ttk + + detected = darkdetect.theme() or "Light" + sv_ttk.set_theme(detected.lower()) +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_tray.py::TestApplySystemTheme -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/tray/_theme.py tests/test_tray.py +git commit -m "feat: add system theme detection for sv-ttk" +``` + +--- + +## Task 8: `_settings.py` — Hilfsfunktionen + +**Files:** +- Create: `whisper_local/tray/_settings.py` +- Modify: `tests/test_tray.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `tests/test_tray.py` ergänzen: + +```python +@pytest.mark.skipif(sys.platform != "win32", reason="Settings nur auf Windows") +class TestCheckHotkeyConflict: + def test_returns_false_when_key_is_free(self): + from unittest.mock import patch, MagicMock + from whisper_local.tray._settings import check_hotkey_conflict + + mock_user32 = MagicMock() + mock_user32.RegisterHotKey.return_value = 1 # Erfolg + with patch("ctypes.windll") as mock_windll: + mock_windll.user32 = mock_user32 + result = check_hotkey_conflict("KEY_F12") + assert result is False + mock_user32.UnregisterHotKey.assert_called_once() + + def test_returns_true_when_key_is_taken(self): + from unittest.mock import patch, MagicMock + from whisper_local.tray._settings import check_hotkey_conflict + + mock_user32 = MagicMock() + mock_user32.RegisterHotKey.return_value = 0 # Belegt + with patch("ctypes.windll") as mock_windll: + mock_windll.user32 = mock_user32 + result = check_hotkey_conflict("KEY_F12") + assert result is True + mock_user32.UnregisterHotKey.assert_not_called() + + def test_returns_false_for_unknown_key(self): + from whisper_local.tray._settings import check_hotkey_conflict + result = check_hotkey_conflict("KEY_NONEXISTENT_999") + assert result is False + + +class TestListMicrophones: + def test_returns_only_input_devices(self): + from unittest.mock import patch + from whisper_local.tray._settings import list_microphones + + fake_devices = [ + {"name": "Speakers", "max_input_channels": 0}, + {"name": "Headset Mic", "max_input_channels": 1}, + {"name": "USB Mic", "max_input_channels": 2}, + ] + with patch("sounddevice.query_devices", return_value=fake_devices): + result = list_microphones() + assert result == [("Headset Mic", 1), ("USB Mic", 2)] + + def test_returns_empty_list_when_no_input(self): + from unittest.mock import patch + from whisper_local.tray._settings import list_microphones + + with patch("sounddevice.query_devices", return_value=[]): + result = list_microphones() + assert result == [] + + +class TestPynputToEvdevKey: + def test_function_key(self): + from pynput.keyboard import Key + from whisper_local.tray._settings import pynput_to_evdev_key + assert pynput_to_evdev_key(Key.f12) == "KEY_F12" + + def test_space_key(self): + from pynput.keyboard import Key + from whisper_local.tray._settings import pynput_to_evdev_key + assert pynput_to_evdev_key(Key.space) == "KEY_SPACE" + + def test_char_key(self): + from pynput.keyboard import KeyCode + from whisper_local.tray._settings import pynput_to_evdev_key + key = KeyCode.from_char("a") + assert pynput_to_evdev_key(key) == "KEY_A" + + def test_unknown_returns_empty(self): + from whisper_local.tray._settings import pynput_to_evdev_key + assert pynput_to_evdev_key(None) == "" +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_tray.py::TestCheckHotkeyConflict tests/test_tray.py::TestListMicrophones tests/test_tray.py::TestPynputToEvdevKey -v +``` + +Erwartet: FAIL — `_settings.py` nicht vorhanden. + +- [ ] **Schritt 3: Hilfsfunktionen implementieren** + +`whisper_local/tray/_settings.py` erstellen (nur Hilfsfunktionen, Dialog folgt in Task 9): + +```python +"""Einstellungs-Dialog für whisper-local (Windows).""" + +from __future__ import annotations + +import ctypes +import threading +from typing import Callable + +import sounddevice as sd + +from whisper_local.config import Config, save_config + + +def pynput_to_evdev_key(key) -> str: + """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" + from pynput.keyboard import Key, KeyCode + + if isinstance(key, Key): + return f"KEY_{key.name.upper()}" + if isinstance(key, KeyCode) and key.char: + return f"KEY_{key.char.upper()}" + return "" + + +def check_hotkey_conflict(evdev_name: str) -> bool: + """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + + try: + pynput_key = _evdev_to_pynput_key(evdev_name) + except ValueError: + return False + + if not isinstance(pynput_key, Key): + return False + + vk = getattr(pynput_key.value, "vk", None) + if vk is None: + return False + + HOTKEY_ID = 0x7FFF + user32 = ctypes.windll.user32 + if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): + user32.UnregisterHotKey(None, HOTKEY_ID) + return False + return True + + +def list_microphones() -> list[tuple[str, int]]: + """Gibt Liste aller Eingabegeräte als (name, index) zurück.""" + devices = sd.query_devices() + return [ + (dev["name"], idx) + for idx, dev in enumerate(devices) + if dev["max_input_channels"] > 0 + ] +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_tray.py::TestCheckHotkeyConflict tests/test_tray.py::TestListMicrophones tests/test_tray.py::TestPynputToEvdevKey -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/tray/_settings.py tests/test_tray.py +git commit -m "feat: add hotkey conflict detection and microphone listing helpers" +``` + +--- + +## Task 9: `_settings.py` — SettingsDialog + +**Files:** +- Modify: `whisper_local/tray/_settings.py` +- Modify: `tests/test_tray.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `tests/test_tray.py` ergänzen: + +```python +@pytest.mark.skipif(sys.platform != "win32", reason="Settings nur auf Windows") +class TestSettingsDialog: + def test_on_save_called_with_new_config(self): + import tkinter as tk + from unittest.mock import patch, MagicMock, call + from whisper_local.config import Config + from whisper_local.tray._settings import SettingsDialog + + saved = [] + dialog = SettingsDialog( + config=Config(hotkey="KEY_F12", microphone=""), + on_save=saved.append, + ) + + # Dialog direkt aufrufen (nicht in Thread), mit gemocktem mainloop + with patch("tkinter.Tk.mainloop"), \ + patch("whisper_local.tray._settings.apply_system_theme"), \ + patch("whisper_local.tray._settings.list_microphones", return_value=[]), \ + patch("whisper_local.tray._settings.save_config") as mock_save: + dialog._run() + + # _run() ruft mainloop auf, der sofort zurückkehrt. + # save() wird nicht automatisch aufgerufen — das ist korrekt. + # Wir prüfen nur, dass _run() ohne Fehler durchläuft. + assert mock_save.call_count == 0 # Noch nicht gespeichert ohne Klick + + def test_on_save_callback_called_when_save_invoked(self): + import tkinter as tk + from unittest.mock import patch, MagicMock + from whisper_local.config import Config + from whisper_local.tray._settings import SettingsDialog + + saved_configs = [] + dialog = SettingsDialog( + config=Config(hotkey="KEY_F12", microphone=""), + on_save=saved_configs.append, + ) + + # _run() intern aufrufen und save() direkt triggern + captured_save_fn = [] + + def fake_button(frame, text, command, **kwargs): + if text == "Speichern": + captured_save_fn.append(command) + mock = MagicMock() + mock.pack = MagicMock() + return mock + + with patch("tkinter.Tk.mainloop"), \ + patch("whisper_local.tray._settings.apply_system_theme"), \ + patch("whisper_local.tray._settings.list_microphones", return_value=[("USB Mic", 0)]), \ + patch("whisper_local.tray._settings.save_config"), \ + patch("tkinter.ttk.Button", side_effect=fake_button): + dialog._run() + + if captured_save_fn: + captured_save_fn[0]() + assert len(saved_configs) == 1 +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_tray.py::TestSettingsDialog -v +``` + +Erwartet: FAIL — `SettingsDialog` nicht vorhanden. + +- [ ] **Schritt 3: SettingsDialog implementieren** + +`whisper_local/tray/_settings.py` — `SettingsDialog`-Klasse am Ende der Datei ergänzen: + +```python +class SettingsDialog: + """Einstellungs-Dialog (läuft in eigenem Thread).""" + + def __init__(self, config: Config, on_save: Callable[[Config], None]): + self._config = config + self._on_save = on_save + + def open(self) -> None: + """Öffnet den Dialog in einem Daemon-Thread.""" + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + + def _run(self) -> None: + import tkinter as tk + from tkinter import ttk + from whisper_local.tray._theme import apply_system_theme + + root = tk.Tk() + root.title("whisper-local – Einstellungen") + root.resizable(False, False) + apply_system_theme(root) + + frame = ttk.Frame(root, padding=16) + frame.pack(fill=tk.BOTH, expand=True) + + # --- Hotkey --- + ttk.Label(frame, text="Hotkey").grid(row=0, column=0, sticky=tk.W, pady=4) + hotkey_var = tk.StringVar(value=self._config.hotkey) + ttk.Label(frame, textvariable=hotkey_var, width=14, relief="sunken").grid( + row=0, column=1, padx=8 + ) + + conflict_var = tk.StringVar() + ttk.Label(frame, textvariable=conflict_var, foreground="orange").grid( + row=1, column=0, columnspan=3, sticky=tk.W + ) + + def record_hotkey(): + hotkey_var.set("...") + conflict_var.set("") + + captured: list[str] = [] + + def on_press(key): + evdev = pynput_to_evdev_key(key) + if evdev: + captured.append(evdev) + return False # Listener stoppen + + def listen(): + from pynput.keyboard import Listener + with Listener(on_press=on_press) as lst: + lst.join() + if captured: + evdev = captured[0] + root.after(0, lambda: hotkey_var.set(evdev)) + if check_hotkey_conflict(evdev): + root.after( + 0, + lambda: conflict_var.set( + "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" + ), + ) + + threading.Thread(target=listen, daemon=True).start() + + ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid( + row=0, column=2, padx=4 + ) + + # --- Mikrofon --- + ttk.Label(frame, text="Mikrofon").grid(row=2, column=0, sticky=tk.W, pady=4) + mics = list_microphones() + mic_names = ["Standard"] + [name for name, _ in mics] + current_mic = self._config.microphone or "Standard" + mic_var = tk.StringVar(value=current_mic) + ttk.Combobox( + frame, textvariable=mic_var, values=mic_names, state="readonly", width=32 + ).grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=4) + + # --- Buttons --- + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=3, column=0, columnspan=3, pady=12, sticky=tk.E) + + def save(): + new_config = Config( + hotkey=hotkey_var.get(), + whisper_model=self._config.whisper_model, + language=self._config.language, + compute_type=self._config.compute_type, + sample_rate=self._config.sample_rate, + channels=self._config.channels, + min_duration=self._config.min_duration, + microphone="" if mic_var.get() == "Standard" else mic_var.get(), + ) + save_config(new_config) + self._on_save(new_config) + root.destroy() + + ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4) + ttk.Button(btn_frame, text="Abbrechen", command=root.destroy).pack(side=tk.RIGHT) + + root.mainloop() +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_tray.py::TestSettingsDialog -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/tray/_settings.py tests/test_tray.py +git commit -m "feat: add SettingsDialog with hotkey recording and microphone selection" +``` + +--- + +## Task 10: `tray/__init__.py` — Factory `create_tray()` + +**Files:** +- Modify: `whisper_local/tray/__init__.py` +- Modify: `tests/test_tray.py` + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `tests/test_tray.py` ergänzen: + +```python +class TestCreateTray: + @pytest.mark.skipif(sys.platform != "win32", reason="Win32TrayApp nur auf Windows") + def test_returns_win32_tray_on_windows(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import Win32TrayApp + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, Win32TrayApp) + + @pytest.mark.skipif(sys.platform == "win32", reason="NoOpTray nur auf nicht-Windows") + def test_returns_noop_tray_on_non_windows(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import NoOpTray + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, NoOpTray) + + def test_appstate_exported_from_package(self): + from whisper_local.tray import AppState + assert AppState.WAITING is not None + assert AppState.RECORDING is not None + assert AppState.TRANSCRIBING is not None +``` + +- [ ] **Schritt 2: Test ausführen — muss scheitern** + +``` +uv run pytest tests/test_tray.py::TestCreateTray -v +``` + +Erwartet: FAIL — `create_tray` nicht in `__init__.py`. + +- [ ] **Schritt 3: `__init__.py` implementieren** + +`whisper_local/tray/__init__.py`: + +```python +"""Tray-Package — plattformspezifische Tray-App.""" + +import sys +from typing import Callable + +from whisper_local.tray._tray import AppState, NoOpTray + + +def create_tray( + on_settings: Callable[[], None], + on_quit: Callable[[], None], +) -> "Win32TrayApp | NoOpTray": + """Gibt den plattformspezifischen Tray zurück.""" + if sys.platform == "win32": + from whisper_local.tray._tray import Win32TrayApp + return Win32TrayApp(on_settings=on_settings, on_quit=on_quit) + return NoOpTray() + + +__all__ = ["create_tray", "AppState"] +``` + +- [ ] **Schritt 4: Tests ausführen — müssen bestehen** + +``` +uv run pytest tests/test_tray.py -v +``` + +Erwartet: alle PASS (Windows), Skips wo plattformspezifisch. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/tray/__init__.py tests/test_tray.py +git commit -m "feat: add create_tray() factory with platform dispatch" +``` + +--- + +## Task 11: `__main__.py` — App-Integration + +**Files:** +- Modify: `whisper_local/__main__.py` +- Modify: `tests/test_main.py` + +- [ ] **Schritt 1: Bestehende Tests anpassen** + +In `tests/test_main.py` alle drei `@patch`-Decorator-Blöcke um `create_tray`-Mock erweitern: + +```python + @patch("whisper_local.__main__.create_tray") + @patch("whisper_local.__main__.Transcriber") + @patch("whisper_local.__main__.create_listener") + @patch("whisper_local.__main__.create_inserter") + def test_app_init(self, mock_inserter_factory, mock_listener_factory, + mock_transcriber_class, mock_tray_factory): + app = App() + assert app.recorder is not None + mock_transcriber_class.assert_called_once() + mock_listener_factory.assert_called_once() + mock_inserter_factory.assert_called_once() + mock_tray_factory.assert_called_once() +``` + +Gleiche Erweiterung für `test_on_press_starts_recording`, `test_on_release_stops_and_transcribes`, `test_on_release_no_audio_skips`. + +Außerdem neue Tests am Ende anfügen: + +```python + @patch("whisper_local.__main__.create_tray") + @patch("whisper_local.__main__.Transcriber") + @patch("whisper_local.__main__.create_listener") + @patch("whisper_local.__main__.create_inserter") + def test_on_press_sets_recording_state( + self, mock_inserter_factory, mock_listener_factory, + mock_transcriber_class, mock_tray_factory + ): + from whisper_local.tray import AppState + mock_tray = MagicMock() + mock_tray_factory.return_value = mock_tray + + app = App() + app.recorder = MagicMock() + + import asyncio + asyncio.run(app.on_press()) + + mock_tray.set_state.assert_called_with(AppState.RECORDING) + + @patch("whisper_local.__main__.create_tray") + @patch("whisper_local.__main__.Transcriber") + @patch("whisper_local.__main__.create_listener") + @patch("whisper_local.__main__.create_inserter") + def test_on_release_sets_waiting_state_after_transcription( + self, mock_inserter_factory, mock_listener_factory, + mock_transcriber_class, mock_tray_factory + ): + from whisper_local.tray import AppState + mock_tray = MagicMock() + mock_tray_factory.return_value = mock_tray + + mock_transcriber = MagicMock() + mock_transcriber.transcribe.return_value = "Text" + mock_transcriber_class.return_value = mock_transcriber + + app = App() + app.recorder = MagicMock() + app.recorder.stop.return_value = np.zeros(16000, dtype=np.float32) + app.inserter = MagicMock() + app.inserter.insert = AsyncMock() + + import asyncio + asyncio.run(app.on_release()) + + calls = [c.args[0] for c in mock_tray.set_state.call_args_list] + assert AppState.TRANSCRIBING in calls + assert calls[-1] == AppState.WAITING +``` + +- [ ] **Schritt 2: Tests ausführen — bestehende müssen scheitern** + +``` +uv run pytest tests/test_main.py -v +``` + +Erwartet: FAIL auf alten Tests (fehlender `create_tray`-Mock) + FAIL auf neuen Tests. + +- [ ] **Schritt 3: `__main__.py` aktualisieren** + +`whisper_local/__main__.py` komplett ersetzen: + +```python +"""Entry-Point für whisper-local.""" + +import asyncio +import logging +import sys + +from whisper_local.config import Config, load_config +from whisper_local.hotkey import create_listener +from whisper_local.inserter import create_inserter +from whisper_local.recorder import Recorder +from whisper_local.transcriber import Transcriber +from whisper_local.tray import AppState, create_tray + +logger = logging.getLogger(__name__) + + +class App: + def __init__(self, config: Config | None = None): + if config is None: + config = load_config() + + self._config = config + self._loop: asyncio.AbstractEventLoop | None = None + self._hotkey_task: asyncio.Task | None = None + + self.recorder = Recorder( + sample_rate=config.sample_rate, + channels=config.channels, + min_duration=config.min_duration, + device=config.microphone or None, + ) + self.transcriber = Transcriber( + model_name=config.whisper_model, + compute_type=config.compute_type, + language=config.language, + ) + self.inserter = create_inserter() + self.hotkey = create_listener(key_name=config.hotkey) + self.hotkey.on_press = self.on_press + self.hotkey.on_release = self.on_release + self.tray = create_tray(on_settings=self._open_settings, on_quit=self._quit) + + async def on_press(self) -> None: + """Callback: Hotkey gedrückt — Aufnahme starten.""" + logger.info("Aufnahme startet...") + self.tray.set_state(AppState.RECORDING) + self.recorder.start() + + async def on_release(self) -> None: + """Callback: Hotkey losgelassen — Aufnahme stoppen, transkribieren, einfügen.""" + audio = self.recorder.stop() + if audio is None: + logger.info("Keine Audio-Daten, übersprungen") + self.tray.set_state(AppState.WAITING) + return + + logger.info("Transkribiere...") + self.tray.set_state(AppState.TRANSCRIBING) + text = self.transcriber.transcribe(audio) + if text: + await self.inserter.insert(text) + self.tray.set_state(AppState.WAITING) + + def _quit(self) -> None: + """Beendet die Anwendung sauber.""" + if self._loop is not None: + self._loop.call_soon_threadsafe(self._loop.stop) + + def _open_settings(self) -> None: + """Öffnet den Einstellungs-Dialog.""" + from whisper_local.tray._settings import SettingsDialog + SettingsDialog(config=self._config, on_save=self._on_config_reload).open() + + def _on_config_reload(self, new_config: Config) -> None: + """Übernimmt neue Konfiguration ohne App-Neustart.""" + self._config = new_config + self.recorder = Recorder( + sample_rate=new_config.sample_rate, + channels=new_config.channels, + min_duration=new_config.min_duration, + device=new_config.microphone or None, + ) + if self._loop is not None: + asyncio.run_coroutine_threadsafe( + self._restart_hotkey(new_config.hotkey), self._loop + ) + + async def _restart_hotkey(self, key_name: str) -> None: + """Stoppt den alten Hotkey-Listener und startet einen neuen.""" + self.hotkey.stop() + await asyncio.sleep(0.1) + self.hotkey = create_listener(key_name=key_name) + self.hotkey.on_press = self.on_press + self.hotkey.on_release = self.on_release + self._hotkey_task = asyncio.create_task(self.hotkey.listen()) + + async def run(self) -> None: + """Startet den Hauptloop.""" + self._loop = asyncio.get_running_loop() + logger.info("whisper-local gestartet, warte auf Hotkey...") + self.tray.start() + self._hotkey_task = asyncio.create_task(self.hotkey.listen()) + await self._hotkey_task + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + app = App() + try: + asyncio.run(app.run()) + except KeyboardInterrupt: + logger.info("Beendet") + sys.exit(0) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Schritt 4: Alle Tests ausführen — müssen bestehen** + +``` +uv run pytest -v +``` + +Erwartet: alle PASS. + +- [ ] **Schritt 5: Commit** + +```bash +git add whisper_local/__main__.py tests/test_main.py +git commit -m "feat: integrate tray icon, settings dialog, and config reload into App" +```