Files
whisper-local/docs/superpowers/specs/2026-04-11-linux-tray-design.md
T
2026-04-11 20:59:27 +02:00

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

  1. find_all_keyboards() — liefert alle InputDevices, 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 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 ü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:

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