diff --git a/docs/superpowers/specs/2026-04-10-tray-icon-design.md b/docs/superpowers/specs/2026-04-10-tray-icon-design.md new file mode 100644 index 0000000..4b482e4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-tray-icon-design.md @@ -0,0 +1,124 @@ +# 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.