Files

125 lines
4.4 KiB
Markdown
Raw Permalink Normal View History

# 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.