Files
whisper-local/docs/superpowers/specs/2026-04-10-tray-icon-design.md
2026-04-10 20:44:42 +02:00

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.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):

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