# Design: Linux-Tray-Icon & Einstellungs-Dialog **Datum:** 2026-04-11 **Plattform:** Linux (KDE Plasma / Wayland — erweitert bestehende Windows-Implementierung) **Vorgänger-Spec:** [2026-04-10-tray-icon-design.md](2026-04-10-tray-icon-design.md) ## Übersicht whisper-local hat bereits einen Tray mit Einstellungs-Dialog für Windows. Diese Arbeit bringt denselben Funktionsumfang auf Linux (Primär-Ziel: KDE Plasma 6 unter Wayland): - Tray-Icon mit drei Zuständen (`WAITING` / `RECORDING` / `TRANSCRIBING`) - Kontextmenü mit „Einstellungen" und „Beenden" - Einstellungs-Dialog zum Ändern von Hotkey und Mikrofon, mit Live-Config-Reload ## Nicht-Ziele - Keine neuen Einstellungs-Felder jenseits der Windows-Version - Kein eigener Qt-/GTK-Stack — wir bleiben bei `pystray` + `tkinter` + `sv-ttk` - Keine Konflikt-Erkennung für den Hotkey (siehe unten) ## Technik-Entscheidungen ### GUI-Toolkit: pystray + tkinter (wie Windows) `pystray` besitzt ein AppIndicator-Backend, das auf KDE Plasma/Wayland über `StatusNotifierItem` / `KStatusNotifierItem` läuft. Das hält Code und Dependencies minimal und vermeidet zwei parallele GUI-Stacks im Projekt. **System-Voraussetzungen** (Arch/CachyOS): `libayatana-appindicator`, `gobject-introspection`, `python-gobject`. Wird im README erwähnt. ### Hotkey-Aufzeichnen: evdev `pynput` funktioniert unter Wayland nicht zuverlässig. Stattdessen liest der „Aufzeichnen"-Modus direkt von allen Tastatur-Devices über `evdev` — derselbe Stack, den der Listener bereits nutzt. Funktioniert identisch unter X11 und Wayland. ### Kein Konflikt-Check auf Linux Unter Win32 prüft `check_hotkey_conflict()` via `RegisterHotKey`, ob eine andere App die Taste global belegt. Auf Linux gibt es dazu kein Äquivalent: `evdev` liest auf Kernel-Ebene und kann nicht „blockiert" werden. KDE-Global-Shortcuts verhindern den evdev-Read nicht. Das Konflikt-Label wird auf Linux schlicht nicht angezeigt. ### Theme-Erkennung: darkdetect (best-effort) `darkdetect` unterstützt Linux über GTK/gsettings, liest aber nicht KDE Plasma direkt. Wir nehmen das in Kauf — im Zweifel fällt das Theme auf Light zurück, was mit sv-ttk akzeptabel aussieht. Eine KDE-spezifische Lösung (`kreadconfig6`) kann später nachgezogen werden. ## Modul-Struktur ``` whisper_local/tray/ ├── __init__.py # create_tray() — Dispatch ├── _icon.py # Pillow-Icons (unverändert) ├── _theme.py # darkdetect + sv-ttk (unverändert) ├── _tray.py # AppState, PystrayApp, NoOpTray ├── _settings.py # SettingsDialog (cross-platform Rahmen) ├── _hotkey_record_pynput.py # Windows-spezifisch (Record + Konflikt-Check) └── _hotkey_record_evdev.py # Linux-spezifisch (Record) ``` ### Plattform-Dispatch ```python # whisper_local/tray/__init__.py def create_tray(on_settings, on_quit): if sys.platform in ("win32", "linux"): from whisper_local.tray._tray import PystrayApp return PystrayApp(on_settings=on_settings, on_quit=on_quit) return NoOpTray() ``` `Win32TrayApp` wird in `PystrayApp` umbenannt. Der Klassen-Code ist heute schon plattformneutral — es gibt nichts Windows-spezifisches darin. ### Hotkey-Recorder-Interface Beide Plattform-Module exportieren dieselbe Funktion: ```python def record_hotkey( on_result: Callable[[str, bool], None], on_cancel: threading.Event, ) -> None: """Blockiert bis der erste Keydown kommt oder on_cancel gesetzt wird. Ruft on_result(evdev_key_name, has_conflict) im Thread auf.""" ``` Auf Linux ist `has_conflict` immer `False`. Der `SettingsDialog` wählt beim Import das passende Modul anhand von `sys.platform`. ## Linux-Hotkey-Recorder (`_hotkey_record_evdev.py`) ### Ablauf 1. `find_all_keyboards()` — liefert alle `InputDevice`s, deren Capabilities `EV_KEY` enthalten (Reuse/Generalisierung der bestehenden `find_keyboard_devices(key_name)`). 2. Alle Devices in einen `selectors.DefaultSelector` registrieren. 3. Schleife mit `selector.select(timeout=0.1)`: - Wenn ein Event gelesen wird und `event.type == EV_KEY and event.value == 1` (Keydown): Key-Name via `ecodes.KEY[event.code]` holen, Callback auslösen, Schleife verlassen. - Wenn `on_cancel.is_set()`: Schleife verlassen. 4. Alle Devices schließen (`device.close()`). ### Warum synchron (selectors) statt asyncio Der `SettingsDialog` läuft in einem eigenen Daemon-Thread ohne asyncio-Loop. Eine kurzlebige synchrone Leseschleife ist einfacher als Loop-Hopping vom Dialog-Thread zum App-Loop für eine einzelne Taste. ### Modifier-Keys Werden nicht gefiltert. Konsistent mit Windows: wer `KEY_LEFTSHIFT` als Hotkey will, darf das. ### Abbruch Wenn der Nutzer den Dialog schließt während der Aufzeichnen-Modus läuft, setzt `close()` des Dialogs das `cancel_event`, das die Record-Schleife beendet und Devices freigibt. ## Bugfix: `EvdevHotkeyListener.stop()` **Problem:** `stop()` in [whisper_local/hotkey/_evdev.py:60](whisper_local/hotkey/_evdev.py#L60) ist heute ein No-Op. Nach Config-Reload (`App._restart_hotkey()`) laufen die alten `_read_device`-Tasks weiter, InputDevices bleiben offen, und der alte + der neue Listener feuern parallel. **Fix (als Teil dieser Arbeit):** ```python class EvdevHotkeyListener: def __init__(self, key_name="KEY_F12"): ... self._tasks: list[asyncio.Task] = [] self._devices: list[InputDevice] = [] def stop(self) -> None: for task in self._tasks: task.cancel() for dev in self._devices: dev.close() self._tasks.clear() self._devices.clear() async def listen(self) -> None: self._devices = find_keyboard_devices(self.key_name) self._tasks = [asyncio.create_task(self._read_device(d)) for d in self._devices] await asyncio.gather(*self._tasks, return_exceptions=True) ``` Damit funktioniert der bestehende `_restart_hotkey()` in [whisper_local/__main__.py:89-96](whisper_local/__main__.py#L89-L96) auch auf Linux korrekt. ## Settings-Dialog: Plattform-Unterschiede - Hotkey-Record-Import wird in `_settings.py` über `sys.platform`-Dispatch gewählt. - Das Konflikt-Label bleibt im Layout, wird aber nur bei `has_conflict=True` befüllt. Da Linux immer `False` zurückgibt, ist das Label dort praktisch unsichtbar. - Der Rest (Mikrofon-Dropdown, Speichern/Abbrechen, Config-Reload-Callback) bleibt unverändert. ## Abhängigkeiten `pyproject.toml` — Marker `sys_platform == 'win32'` wird entfernt von: ```toml "pystray>=0.19.0", "Pillow>=10.0.0", "sv-ttk>=2.6.0", "darkdetect>=0.8.0", ``` `pynput` und `pywin32` bleiben Windows-only. `evdev` bleibt Linux-only. ## Tests - `tests/test_tray.py`: - Bestehende `_icon.py`-Tests laufen unverändert auf beiden Plattformen. - Neu: `test_create_tray_linux` — monkeypatcht `sys.platform` und prüft, dass `PystrayApp` (nicht `NoOpTray`) geliefert wird. - Neu: `test_record_hotkey_evdev` mit gemocktem Device, das einen `EV_KEY`-Event liefert. Mark: `skipif(sys.platform != "linux")`. - `tests/test_hotkey.py`: - Neu: Test der verifiziert, dass `EvdevHotkeyListener.stop()` die Tasks cancelt und Devices schließt. Mark: `skipif(sys.platform != "linux")`. - Der Dialog-Mainloop selbst wird wie bisher nicht direkt getestet. ## Dateien, die geändert werden | Datei | Änderung | |---|---| | `whisper_local/tray/__init__.py` | Dispatch für `linux` ergänzt | | `whisper_local/tray/_tray.py` | `Win32TrayApp` → `PystrayApp` umbenannt | | `whisper_local/tray/_settings.py` | Hotkey-Record ausgelagert, Konflikt-Label plattform-sensitiv | | `whisper_local/tray/_hotkey_record_pynput.py` | **Neu** — Extraktion aus `_settings.py` | | `whisper_local/tray/_hotkey_record_evdev.py` | **Neu** — evdev-basierter Recorder | | `whisper_local/hotkey/_evdev.py` | `stop()` cancelt Tasks + schließt Devices | | `pyproject.toml` | `sys_platform`-Marker für 4 Deps entfernt | | `tests/test_tray.py` | Linux-Tests ergänzt | | `tests/test_hotkey.py` | Test für `stop()`-Fix | Keine Änderung an `__main__.py` — der `create_tray(...)`-Call bleibt identisch. ## Offene Punkte / Nicht-Ziele für später - KDE-native Theme-Erkennung (`kreadconfig6`) — nur wenn darkdetect sich in der Praxis als untauglich erweist. - Dokumentation der System-Dependencies im README — als separater kleiner Commit.