125 lines
4.4 KiB
Markdown
125 lines
4.4 KiB
Markdown
|
|
# 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.
|