# Design: Tray-Icon mit Einstellungs-Dialog **Datum:** 2026-04-10 **Plattform:** Windows (Linux-Support folgt später) ## Übersicht whisper-local erhält ein Tray-Icon in der Windows-Taskleiste, das den aktuellen App-Zustand anzeigt. Per Rechtsklick-Menü kann der Nutzer Einstellungen öffnen oder die App beenden. ## Zustände & Icons Drei Zustände werden über ein Mikrofon-Symbol mit Farbe unterschieden: | Zustand | Farbe | Bedeutung | |---|---|---| | `WAITING` | Grau | Warte auf Hotkey-Druck | | `RECORDING` | Rot | Aufnahme läuft | | `TRANSCRIBING` | Gelb | Transkription läuft | Icons werden programmatisch via `Pillow` generiert — keine Bilddateien im Repository. ## Modulstruktur Neues Package `whisper_local/tray/`: ``` whisper_local/tray/ ├── __init__.py # create_tray() Factory ├── _icon.py # Icon-Generierung (Pillow, 3 Zustände) ├── _tray.py # Win32TrayApp: pystray-Integration, AppState-Enum, Menü ├── _settings.py # SettingsDialog: tkinter + sv-ttk └── _theme.py # System-Theme-Erkennung (darkdetect) + sv-ttk anwenden ``` `create_tray()` gibt auf Linux ein No-Op-Stub zurück, sodass der Rest der App plattformunabhängig bleibt. ## AppState-Enum ```python class AppState(enum.Enum): WAITING = "waiting" RECORDING = "recording" TRANSCRIBING = "transcribing" ``` ## Tray-Integration (`_tray.py`) - `pystray.Icon` läuft in einem eigenen Thread (Pflicht bei pystray). - Rechtsklick-Menü: **Einstellungen** | **Beenden** - „Beenden" ruft `loop.call_soon_threadsafe(loop.stop)` auf, um den asyncio-Loop sauber zu stoppen. - `set_state(AppState)` tauscht das Icon aus; thread-sicher via `icon.icon = ...`. ## Einstellungs-Dialog (`_settings.py`) Der Dialog öffnet sich in einem dedizierten Thread. Änderungen werden erst bei „Speichern" übernommen. ### Felder **Hotkey** - Button „Aufzeichnen": Klick aktiviert Lausch-Modus; nächste Tastenbetätigung wird als neuer Hotkey übernommen und im evdev-Format (`KEY_F12`) gespeichert. - Direkt darunter: Konflikt-Warnung (oranges Label), falls die Taste bereits von einer anderen App via Win32-`RegisterHotKey` belegt ist. - Hinweis: Apps mit Low-Level-Hooks (wie whisper-local selbst) können nicht erkannt werden. **Mikrofon** - Dropdown, befüllt via `sounddevice.query_devices()`. - Zeigt Gerätename + Index; speichert den Gerätenamen in `config.toml`. ### Buttons `Speichern` — übernimmt Änderungen in `config.toml` und löst Config-Reload aus `Abbrechen` — verwirft Änderungen ### Konflikt-Erkennung `ctypes.windll.user32.RegisterHotKey(None, id, 0, vk)` wird kurz aufgerufen und sofort mit `UnregisterHotKey` rückgängig gemacht. Schlägt `RegisterHotKey` fehl, ist die Taste belegt. ## Theme-Support (`_theme.py`) - `darkdetect.theme()` liest das aktive Windows-System-Theme (`"Light"` / `"Dark"`). - `sv_ttk.set_theme("light"/"dark")` wird beim Öffnen des Dialogs angewendet. - Das Theme wird bei jedem Dialog-Öffnen neu gelesen (kein Live-Update während die App läuft). ## Abhängigkeiten Neue Einträge in `pyproject.toml` (alle Windows-only): ```toml "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'", ``` ## Integration in `App` (`__main__.py`) ```python self.tray = create_tray(on_settings=self._open_settings, on_quit=self._quit) async def on_press(self): self.tray.set_state(AppState.RECORDING) self.recorder.start() async def on_release(self): audio = self.recorder.stop() self.tray.set_state(AppState.TRANSCRIBING) text = self.transcriber.transcribe(audio) await self.inserter.insert(text) self.tray.set_state(AppState.WAITING) async def run(self): self.tray.start() await self.hotkey.listen() ``` ## Config-Reload Nach „Speichern" im Dialog ruft `SettingsDialog` einen übergebenen Callback auf. `App` initialisiert daraufhin `PynputHotkeyListener` und `Recorder` mit den neuen Werten neu — kein Neustart der App erforderlich. ## Testing - `_icon.py`: Unit-Test prüft, dass für jeden `AppState` ein PIL-Image zurückgegeben wird. - `_tray.py`: `set_state()` mit gemocktem `pystray.Icon` testen. - `_settings.py`: Konflikt-Erkennung mit gemocktem `ctypes.windll` testen; Config-Reload-Callback testen. - Tray-Tests mit `@pytest.mark.skipif(sys.platform != "win32", ...)` markieren.