docs: Design-Spec für Linux-Tray & Settings-Dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user