From 514e9fef9cbbf0fe433576bbf00bcae9924b258c Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Sat, 11 Apr 2026 20:59:27 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20Design-Spec=20f=C3=BCr=20Linux-Tray=20&?= =?UTF-8?q?=20Settings-Dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-04-11-linux-tray-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-11-linux-tray-design.md diff --git a/docs/superpowers/specs/2026-04-11-linux-tray-design.md b/docs/superpowers/specs/2026-04-11-linux-tray-design.md new file mode 100644 index 0000000..a561f1e --- /dev/null +++ b/docs/superpowers/specs/2026-04-11-linux-tray-design.md @@ -0,0 +1,182 @@ +# 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.