Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4.4 KiB
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
class AppState(enum.Enum):
WAITING = "waiting"
RECORDING = "recording"
TRANSCRIBING = "transcribing"
Tray-Integration (_tray.py)
pystray.Iconlä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 viaicon.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-
RegisterHotKeybelegt 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):
"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)
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 jedenAppStateein PIL-Image zurückgegeben wird._tray.py:set_state()mit gemocktempystray.Icontesten._settings.py: Konflikt-Erkennung mit gemocktemctypes.windlltesten; Config-Reload-Callback testen.- Tray-Tests mit
@pytest.mark.skipif(sys.platform != "win32", ...)markieren.