# 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" ```