docs: add tray icon + settings dialog design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user