Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
8.3 KiB
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
Ü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
# 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:
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
find_all_keyboards()— liefert alleInputDevices, deren CapabilitiesEV_KEYenthalten (Reuse/Generalisierung der bestehendenfind_keyboard_devices(key_name)).- Alle Devices in einen
selectors.DefaultSelectorregistrieren. - Schleife mit
selector.select(timeout=0.1):- Wenn ein Event gelesen wird und
event.type == EV_KEY and event.value == 1(Keydown): Key-Name viaecodes.KEY[event.code]holen, Callback auslösen, Schleife verlassen. - Wenn
on_cancel.is_set(): Schleife verlassen.
- Wenn ein Event gelesen wird und
- 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 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):
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 auch auf Linux korrekt.
Settings-Dialog: Plattform-Unterschiede
- Hotkey-Record-Import wird in
_settings.pyübersys.platform-Dispatch gewählt. - Das Konflikt-Label bleibt im Layout, wird aber nur bei
has_conflict=Truebefüllt. Da Linux immerFalsezurü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:
"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— monkeypatchtsys.platformund prüft, dassPystrayApp(nichtNoOpTray) geliefert wird. - Neu:
test_record_hotkey_evdevmit gemocktem Device, das einenEV_KEY-Event liefert. Mark:skipif(sys.platform != "linux").
- Bestehende
tests/test_hotkey.py:- Neu: Test der verifiziert, dass
EvdevHotkeyListener.stop()die Tasks cancelt und Devices schließt. Mark:skipif(sys.platform != "linux").
- Neu: Test der verifiziert, dass
- 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.