diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2d893a --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# whisper-local + +Lokales Speech-to-Text mit globalem Hotkey. Hotkey halten → Sprechen → loslassen → faster-whisper transkribiert lokal → Text wird ins aktive Textfeld eingefügt. Kein Netzwerk, kein Cloud-Service. + +Läuft als Hintergrunddienst mit Tray-Icon auf **Linux (KDE Plasma / Wayland)** und **Windows 10/11**. + +## Features + +- Systemweiter Hotkey (Standard: `F12`), konfigurierbar über den Einstellungs-Dialog im Tray +- Einfügen ins aktive Textfeld unabhängig von der Anwendung (Browser, Terminal, IDE …) +- Mikrofon-Auswahl über Tray-Einstellungen +- Lokale Transkription via [faster-whisper](https://github.com/SYSTRAN/faster-whisper) +- Tray-Icon mit drei Zuständen: warten / aufnehmen / transkribieren + +## Systemabhängigkeiten + +### Linux (Arch / CachyOS) + +```bash +sudo pacman -S ydotool wl-clipboard libayatana-appindicator gobject-introspection +``` + +- `ydotool` — simuliert Tastatureingaben unter Wayland (Text-Einfügen via `Ctrl+V`) +- `wl-clipboard` — Clipboard-Zugriff unter Wayland (`wl-copy`) +- `libayatana-appindicator` + `gobject-introspection` — Tray-Icon über StatusNotifierItem (KDE/Wayland) + +Damit `ydotool` ohne `sudo` funktioniert, muss der `ydotoold`-Daemon laufen und der Benutzer in der `input`-Gruppe sein: + +```bash +sudo systemctl enable --now ydotool +sudo usermod -aG input $USER +# Neu-Login nötig +``` + +Der Benutzer braucht außerdem Lesezugriff auf `/dev/input/event*`, was die `input`-Gruppen-Mitgliedschaft abdeckt. + +### Windows + +Keine System-Dependencies. `pynput` und `pywin32` werden automatisch via uv installiert. + +## Installation + +Voraussetzung: [uv](https://github.com/astral-sh/uv) (Python-Paketmanager). + +```bash +git clone whisper-local +cd whisper-local +uv sync +``` + +`uv sync` installiert plattformspezifisch: + +- **Linux**: `evdev`, `PyGObject`, `pystray`, `Pillow`, `sv-ttk`, `darkdetect` +- **Windows**: `pynput`, `pywin32`, `pystray`, `Pillow`, `sv-ttk`, `darkdetect` + +## Konfiguration + +Die Konfigurationsdatei wird beim ersten Start angelegt. Vorlage: [config.example.toml](config.example.toml). + +- **Linux**: `~/.config/whisper-local/config.toml` +- **Windows**: `%APPDATA%\whisper-local\config.toml` + +Hotkey und Mikrofon lassen sich auch direkt über den Einstellungs-Dialog im Tray ändern (Rechtsklick aufs Tray-Symbol → „Einstellungen"). Änderungen greifen sofort ohne Neustart. + +Key-Namen folgen dem evdev-Format (`KEY_F12`, `KEY_LEFTSHIFT`, …) — auch unter Windows. + +## Starten + +```bash +uv run whisper-local +``` + +Beim ersten Start lädt faster-whisper das Whisper-Modell (Standard: `small`) herunter. Danach erscheint das Tray-Icon und der Hotkey ist aktiv. + +### Autostart unter Linux (systemd user unit) + +```bash +mkdir -p ~/.config/systemd/user +cp systemd/whisper-local.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now whisper-local.service +``` + +Das `whisper-local`-Executable muss dafür in `~/.local/bin` verfügbar sein (`uv tool install .` oder Pfad in der Unit anpassen). + +## Entwicklung + +```bash +uv run pytest # Tests +uv run python -m whisper_local # Direkt starten +``` + +Plattform-spezifische Tests werden über `@pytest.mark.skipif(sys.platform != ...)` übersprungen. + +Architektur- und Designdokumente liegen unter [docs/superpowers/](docs/superpowers/). + +## Lizenz + +TBD diff --git a/docs/superpowers/plans/2026-04-11-linux-tray.md b/docs/superpowers/plans/2026-04-11-linux-tray.md new file mode 100644 index 0000000..5192b61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-linux-tray.md @@ -0,0 +1,930 @@ +# Linux-Tray & Settings-Dialog Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Tray-Icon mit Einstellungs-Dialog für Linux/KDE/Wayland auf Basis des bestehenden Windows-Codes aktivieren. + +**Architecture:** Wiederverwendung von `pystray` + `tkinter` + `sv-ttk` (plattformunabhängiger Stack). Plattformspezifisch bleibt nur das Hotkey-Aufzeichnen (Linux: evdev, Windows: pynput) und der Konflikt-Check (nur Windows). + +**Tech Stack:** Python 3.13, pystray, tkinter, sv-ttk, darkdetect, Pillow, evdev, sounddevice + +**Spec:** [docs/superpowers/specs/2026-04-11-linux-tray-design.md](../specs/2026-04-11-linux-tray-design.md) + +--- + +## Dateistruktur + +| Datei | Rolle | +|---|---| +| `whisper_local/tray/__init__.py` | `create_tray()` dispatch (win32 + linux → PystrayApp) | +| `whisper_local/tray/_tray.py` | `AppState`, `PystrayApp` (umbenannt von Win32TrayApp), `NoOpTray` | +| `whisper_local/tray/_settings.py` | `SettingsDialog` + plattformübergreifende Helfer | +| `whisper_local/tray/_hotkey_record_pynput.py` | **Neu** — Windows-Record + Konflikt-Check (Extraktion) | +| `whisper_local/tray/_hotkey_record_evdev.py` | **Neu** — Linux-Record via evdev | +| `whisper_local/hotkey/_evdev.py` | `stop()`-Bugfix (Tasks canceln + Devices schließen) | +| `pyproject.toml` | `sys_platform == 'win32'`-Marker entfernen bei gemeinsamen Deps | +| `tests/test_tray.py` | Neue Tests für Linux-Record + Dispatch | +| `tests/test_hotkey.py` | Neuer Test für `stop()`-Fix | + +--- + +## Task 1: `Win32TrayApp` → `PystrayApp` umbenennen + +Struktureller Refactor ohne Verhaltensänderung. Macht den Klassennamen plattformneutral, damit Task 6 den Dispatch erweitern kann. + +**Files:** +- Modify: `whisper_local/tray/_tray.py` +- Modify: `whisper_local/tray/__init__.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Rename Klasse in `_tray.py`** + +Ändere die Klasse in [whisper_local/tray/_tray.py:14](whisper_local/tray/_tray.py#L14): + +```python +class PystrayApp: + """Tray-Icon via pystray — cross-platform (Windows + Linux).""" + + def __init__(self, on_settings: Callable[[], None], on_quit: Callable[[], None]): + self._on_settings = on_settings + self._on_quit = on_quit + self._icon = None + + def start(self) -> None: + """Startet pystray in einem Daemon-Thread.""" + import pystray + from whisper_local.tray._icon import create_icon + + menu = pystray.Menu( + pystray.MenuItem("Einstellungen", self._menu_settings), + pystray.MenuItem("Beenden", self._menu_quit), + ) + self._icon = pystray.Icon( + "whisper-local", + create_icon(AppState.WAITING), + "whisper-local", + menu, + ) + thread = threading.Thread(target=self._icon.run, daemon=True) + thread.start() + + def set_state(self, state: AppState) -> None: + """Tauscht das Icon aus (thread-sicher).""" + if self._icon is not None: + from whisper_local.tray._icon import create_icon + self._icon.icon = create_icon(state) + + def _menu_settings(self, icon, item) -> None: + self._on_settings() + + def _menu_quit(self, icon, item) -> None: + if self._icon is not None: + self._icon.stop() + self._on_quit() +``` + +- [ ] **Step 2: `__init__.py` anpassen** + +Ersetze in [whisper_local/tray/__init__.py](whisper_local/tray/__init__.py) die Referenzen: + +```python +"""Tray-Package — plattformspezifische Tray-App.""" + +import sys +from typing import Callable + +from whisper_local.tray._tray import AppState, NoOpTray + + +def create_tray( + on_settings: Callable[[], None], + on_quit: Callable[[], None], +) -> "PystrayApp | NoOpTray": + """Gibt den plattformspezifischen Tray zurück.""" + if sys.platform == "win32": + from whisper_local.tray._tray import PystrayApp + return PystrayApp(on_settings=on_settings, on_quit=on_quit) + return NoOpTray() + + +__all__ = ["create_tray", "AppState"] +``` + +(Linux-Dispatch kommt in Task 6 — jetzt erstmal nur umbenennen.) + +- [ ] **Step 3: Tests anpassen** + +In `tests/test_tray.py` alle Vorkommen von `Win32TrayApp` durch `PystrayApp` ersetzen: + +- Zeile 28: `class TestWin32TrayApp:` → `class TestPystrayApp:` +- Zeile 31: `from whisper_local.tray._tray import AppState, Win32TrayApp` → `... PystrayApp` +- Zeile 33: `Win32TrayApp(...)` → `PystrayApp(...)` +- Zeile 44: dito +- Zeile 46: `Win32TrayApp(...)` → `PystrayApp(...)` +- Zeile 248-255 (`test_returns_win32_tray_on_windows`): Import und Assertion auf `PystrayApp` anpassen. Testname umbenennen zu `test_returns_pystray_on_windows`. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `uv run pytest tests/test_tray.py -v` +Expected: Alle bisher grünen Tests bleiben grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/_tray.py whisper_local/tray/__init__.py tests/test_tray.py +git commit -m "refactor: rename Win32TrayApp to PystrayApp" +``` + +--- + +## Task 2: Windows-Hotkey-Record in eigenes Modul extrahieren + +Bewegt die Windows-spezifischen Record-/Konflikt-Funktionen aus `_settings.py` in ein neues Modul `_hotkey_record_pynput.py`. Verhaltenserhaltend, macht Platz für die Linux-Variante. + +**Files:** +- Create: `whisper_local/tray/_hotkey_record_pynput.py` +- Modify: `whisper_local/tray/_settings.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Neues Modul anlegen** + +Erstelle `whisper_local/tray/_hotkey_record_pynput.py`: + +```python +"""Hotkey-Aufzeichnung und Konflikt-Erkennung für Windows (pynput + Win32).""" + +import ctypes +import threading +from typing import Callable + + +def pynput_to_evdev_key(key) -> str: + """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" + from pynput.keyboard import Key, KeyCode + + if isinstance(key, Key): + return f"KEY_{key.name.upper()}" + if isinstance(key, KeyCode) and key.char: + return f"KEY_{key.char.upper()}" + return "" + + +def check_hotkey_conflict(evdev_name: str) -> bool: + """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + + try: + pynput_key = _evdev_to_pynput_key(evdev_name) + except ValueError: + return False + + if not isinstance(pynput_key, Key): + return False + + vk = getattr(pynput_key.value, "vk", None) + if vk is None: + return False + + HOTKEY_ID = 0x7FFF + user32 = ctypes.windll.user32 + if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): + user32.UnregisterHotKey(None, HOTKEY_ID) + return False + return True + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis der nächste Keydown kommt oder cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. + """ + from pynput.keyboard import Listener + + captured: list[str] = [] + + def on_press(key): + evdev = pynput_to_evdev_key(key) + if evdev: + captured.append(evdev) + return False # Listener stoppen + + with Listener(on_press=on_press) as lst: + # Poll cancel_event while listener runs in separate thread + while lst.running and not cancel_event.is_set(): + lst.join(timeout=0.1) + if lst.running: + lst.stop() + + if captured and not cancel_event.is_set(): + evdev = captured[0] + on_result(evdev, check_hotkey_conflict(evdev)) +``` + +- [ ] **Step 2: `_settings.py` anpassen — Helper entfernen, Record-Logik per Dispatch** + +Ersetze [whisper_local/tray/_settings.py](whisper_local/tray/_settings.py) komplett mit: + +```python +"""Einstellungs-Dialog für whisper-local (cross-platform).""" + +from __future__ import annotations + +import sys +import threading +from typing import Callable + +import sounddevice as sd + +from whisper_local.config import Config, save_config +from whisper_local.tray._theme import apply_system_theme + + +def list_microphones() -> list[tuple[str, int]]: + """Gibt Liste aller Eingabegeräte als (name, index) zurück.""" + devices = sd.query_devices() + return [ + (dev["name"], idx) + for idx, dev in enumerate(devices) + if dev["max_input_channels"] > 0 + ] + + +def _get_record_hotkey(): + """Wählt das plattformspezifische record_hotkey-Callable.""" + if sys.platform == "win32": + from whisper_local.tray._hotkey_record_pynput import record_hotkey + return record_hotkey + from whisper_local.tray._hotkey_record_evdev import record_hotkey + return record_hotkey + + +class SettingsDialog: + """Einstellungs-Dialog (läuft in eigenem Thread).""" + + def __init__(self, config: Config, on_save: Callable[[Config], None]): + self._config = config + self._on_save = on_save + self._cancel_event = threading.Event() + + def open(self) -> None: + """Öffnet den Dialog in einem Daemon-Thread.""" + thread = threading.Thread(target=self._run, daemon=True) + thread.start() + + def _run(self) -> None: + import tkinter as tk + from tkinter import ttk + + root = tk.Tk() + root.title("whisper-local – Einstellungen") + root.resizable(False, False) + apply_system_theme(root) + + frame = ttk.Frame(root, padding=16) + frame.pack(fill=tk.BOTH, expand=True) + + # --- Hotkey --- + ttk.Label(frame, text="Hotkey").grid(row=0, column=0, sticky=tk.W, pady=4) + hotkey_var = tk.StringVar(value=self._config.hotkey) + ttk.Label(frame, textvariable=hotkey_var, width=14, relief="sunken").grid( + row=0, column=1, padx=8 + ) + + conflict_var = tk.StringVar() + ttk.Label(frame, textvariable=conflict_var, foreground="orange").grid( + row=1, column=0, columnspan=3, sticky=tk.W + ) + + record_hotkey = _get_record_hotkey() + + def do_record(): + hotkey_var.set("...") + conflict_var.set("") + + def on_result(evdev_key: str, has_conflict: bool): + root.after(0, lambda: hotkey_var.set(evdev_key)) + if has_conflict: + root.after( + 0, + lambda: conflict_var.set( + "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" + ), + ) + + def worker(): + record_hotkey(on_result, self._cancel_event) + + threading.Thread(target=worker, daemon=True).start() + + ttk.Button(frame, text="Aufzeichnen", command=do_record).grid( + row=0, column=2, padx=4 + ) + + # --- Mikrofon --- + ttk.Label(frame, text="Mikrofon").grid(row=2, column=0, sticky=tk.W, pady=4) + mics = list_microphones() + mic_names = ["Standard"] + [name for name, _ in mics] + current_mic = self._config.microphone or "Standard" + mic_var = tk.StringVar(value=current_mic) + ttk.Combobox( + frame, textvariable=mic_var, values=mic_names, state="readonly", width=32 + ).grid(row=2, column=1, columnspan=2, sticky=tk.W, padx=4) + + # --- Buttons --- + btn_frame = ttk.Frame(frame) + btn_frame.grid(row=3, column=0, columnspan=3, pady=12, sticky=tk.E) + + def save(): + new_config = Config( + hotkey=hotkey_var.get(), + whisper_model=self._config.whisper_model, + language=self._config.language, + compute_type=self._config.compute_type, + sample_rate=self._config.sample_rate, + channels=self._config.channels, + min_duration=self._config.min_duration, + microphone="" if mic_var.get() == "Standard" else mic_var.get(), + ) + save_config(new_config) + self._on_save(new_config) + self._cancel_event.set() + root.destroy() + + def cancel(): + self._cancel_event.set() + root.destroy() + + ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4) + ttk.Button(btn_frame, text="Abbrechen", command=cancel).pack(side=tk.RIGHT) + + root.protocol("WM_DELETE_WINDOW", cancel) + root.mainloop() +``` + +- [ ] **Step 3: Tests umbiegen** + +In `tests/test_tray.py`: + +1. `TestCheckHotkeyConflict`: Import ändern von `whisper_local.tray._settings` auf `whisper_local.tray._hotkey_record_pynput`. +2. `TestPynputToEvdevKey`: Import ändern von `whisper_local.tray._settings` auf `whisper_local.tray._hotkey_record_pynput`. +3. `TestListMicrophones`: bleibt (liegt noch in `_settings`). +4. `TestSettingsDialog.test_on_save_callback_called_when_save_invoked`: Der Patch `patch("whisper_local.tray._settings.apply_system_theme")` bleibt, aber die Referenz auf `pynput_to_evdev_key` etc. existiert nicht mehr in `_settings`. Prüfe, dass dieser Test noch läuft ohne Referenzen auf ausgelagerte Funktionen. + +- [ ] **Step 4: Tests laufen lassen** + +Run: `uv run pytest tests/test_tray.py -v` +Expected: Alle bisher grünen Tests bleiben grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/_hotkey_record_pynput.py whisper_local/tray/_settings.py tests/test_tray.py +git commit -m "refactor: Windows-Hotkey-Record in eigenes Modul auslagern" +``` + +--- + +## Task 3: Linux-Hotkey-Record implementieren (TDD) + +Neue Funktion `record_hotkey()` auf Basis von `evdev` + `selectors`. Blockiert bis zum ersten Keydown oder Cancel. + +**Files:** +- Create: `whisper_local/tray/_hotkey_record_evdev.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Failing Test schreiben** + +Füge in `tests/test_tray.py` am Ende ein: + +```python +@pytest.mark.skipif(sys.platform != "linux", reason="evdev-Record nur auf Linux") +class TestRecordHotkeyEvdev: + def test_first_keydown_triggers_on_result(self): + import threading + from unittest.mock import MagicMock, patch + + # Fake evdev-Device mit einem Keydown-Event + fake_event = MagicMock() + fake_event.type = 1 # EV_KEY + fake_event.code = 88 # KEY_F12 (ecodes.KEY_F12 == 88) + fake_event.value = 1 # Keydown + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([fake_event]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + def on_result(name, conflict): + results.append((name, conflict)) + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ): + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(on_result, cancel) + + assert results == [("KEY_F12", False)] + fake_device.close.assert_called_once() + + def test_cancel_event_stops_recording(self): + import threading + from unittest.mock import MagicMock, patch + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([]) # keine Events + fake_device.close = MagicMock() + + cancel = threading.Event() + cancel.set() # sofort abbrechen + + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ): + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [] + fake_device.close.assert_called_once() + + def test_ignores_key_up_events(self): + import threading + from unittest.mock import MagicMock, patch + + key_up = MagicMock() + key_up.type = 1 # EV_KEY + key_up.code = 88 + key_up.value = 0 # Key-Up + + key_down = MagicMock() + key_down.type = 1 + key_down.code = 88 + key_down.value = 1 + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([key_up, key_down]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ): + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [("KEY_F12", False)] +``` + +- [ ] **Step 2: Test ausführen — erwartet FAIL** + +Run: `uv run pytest tests/test_tray.py::TestRecordHotkeyEvdev -v` +Expected: `ModuleNotFoundError: No module named 'whisper_local.tray._hotkey_record_evdev'` + +- [ ] **Step 3: Modul `_hotkey_record_evdev.py` implementieren** + +Erstelle `whisper_local/tray/_hotkey_record_evdev.py`: + +```python +"""Hotkey-Aufzeichnung via evdev (Linux).""" + +import selectors +import threading +from typing import Callable + +import evdev +from evdev import InputDevice, ecodes + + +def find_all_keyboards() -> list[InputDevice]: + """Gibt alle Input-Devices zurück, die EV_KEY-Events liefern können.""" + keyboards: list[InputDevice] = [] + for path in evdev.list_devices(): + try: + device = InputDevice(path) + except (PermissionError, OSError): + continue + capabilities = device.capabilities() + if ecodes.EV_KEY in capabilities: + keyboards.append(device) + else: + device.close() + return keyboards + + +def _keycode_to_name(code: int) -> str: + """Übersetzt evdev-Keycode zu Key-Namen. Gibt '' bei unbekanntem Code.""" + name = ecodes.KEY.get(code) + if isinstance(name, list): + return name[0] + if isinstance(name, str): + return name + return "" + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis zum ersten Keydown oder bis cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. has_conflict ist auf + Linux immer False — es gibt kein Äquivalent zum Win32-RegisterHotKey-Check. + """ + devices = find_all_keyboards() + if not devices: + return + + selector = selectors.DefaultSelector() + try: + for dev in devices: + selector.register(dev.fd, selectors.EVENT_READ, dev) + + captured: str | None = None + while captured is None and not cancel_event.is_set(): + for key, _mask in selector.select(timeout=0.1): + dev: InputDevice = key.data + for event in dev.read(): + if event.type == ecodes.EV_KEY and event.value == 1: + captured = _keycode_to_name(event.code) + break + if captured: + break + + if captured and not cancel_event.is_set(): + on_result(captured, False) + finally: + selector.close() + for dev in devices: + dev.close() +``` + +- [ ] **Step 4: Tests ausführen — erwartet PASS** + +Run: `uv run pytest tests/test_tray.py::TestRecordHotkeyEvdev -v` +Expected: Alle drei Tests grün. + +Falls `fake_device.read()` nicht wie erwartet funktioniert, weil der Selector `selector.select()` blockt: Die Test-Strategie muss den Selector mocken. Alternative: + +```python +with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], +), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" +) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + # select() liefert den Device-Key zurück + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + ... +``` + +Falls der erste Testlauf scheitert, ziehe den Selector-Mock nach. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/_hotkey_record_evdev.py tests/test_tray.py +git commit -m "feat: Linux-Hotkey-Record via evdev" +``` + +--- + +## Task 4: `EvdevHotkeyListener.stop()` reparieren (TDD) + +Bestehender Bug: `stop()` cancelt die Read-Tasks nicht und schließt die Devices nicht. Das macht den Config-Reload nach Einstellungsänderung kaputt. + +**Files:** +- Modify: `whisper_local/hotkey/_evdev.py` +- Modify: `tests/test_hotkey.py` + +- [ ] **Step 1: Failing Test schreiben** + +Füge in `tests/test_hotkey.py` am Ende ein: + +```python +@pytest.mark.skipif(sys.platform != "linux", reason="evdev nur auf Linux") +class TestEvdevListenerStop: + @pytest.mark.asyncio + async def test_stop_cancels_tasks_and_closes_devices(self): + from unittest.mock import MagicMock, patch + from whisper_local.hotkey._evdev import EvdevHotkeyListener + + listener = EvdevHotkeyListener("KEY_F12") + + async def never_ending(device): + while True: + await asyncio.sleep(1) + + fake_device = MagicMock() + fake_device.close = MagicMock() + + with patch( + "whisper_local.hotkey._evdev.find_keyboard_devices", + return_value=[fake_device], + ), patch.object(listener, "_read_device", side_effect=never_ending): + listen_task = asyncio.create_task(listener.listen()) + await asyncio.sleep(0.05) # Loop-Schritt, damit listen() startet + + assert len(listener._tasks) == 1 + listener.stop() + await asyncio.sleep(0.05) + + assert listener._tasks == [] + fake_device.close.assert_called_once() + + # listen() sollte sauber zurückkehren + await asyncio.wait_for(listen_task, timeout=1.0) +``` + +- [ ] **Step 2: Test ausführen — erwartet FAIL** + +Run: `uv run pytest tests/test_hotkey.py::TestEvdevListenerStop -v` +Expected: FAIL — `listener._tasks` existiert nicht. + +- [ ] **Step 3: Listener patchen** + +Ersetze [whisper_local/hotkey/_evdev.py](whisper_local/hotkey/_evdev.py) komplett mit: + +```python +"""Hotkey-Listener via evdev für Push-to-Talk (Linux).""" + +import asyncio +import logging + +import evdev +from evdev import InputDevice, categorize, ecodes + +from whisper_local.hotkey import AsyncCallback + +logger = logging.getLogger(__name__) + + +def find_keyboard_devices(key_name: str) -> list[InputDevice]: + """Findet alle Devices die den angegebenen Key unterstützen.""" + matches = [] + for path in evdev.list_devices(): + device = InputDevice(path) + capabilities = device.capabilities(verbose=True) + for (etype_name, _etype_code), events in capabilities.items(): + if etype_name == "EV_KEY": + key_names = [name for name, _code in events] + if key_name in key_names: + logger.info("Device mit %s gefunden: %s (%s)", key_name, device.name, device.path) + matches.append(device) + break + if not matches: + raise RuntimeError(f"Kein Device mit {key_name} gefunden in /dev/input/") + return matches + + +class EvdevHotkeyListener: + def __init__(self, key_name: str = "KEY_F12"): + self.key_name = key_name + self.key_code = ecodes.ecodes.get(key_name) + if self.key_code is None: + raise ValueError(f"Unbekannter Key-Name: {key_name}") + self.on_press: AsyncCallback | None = None + self.on_release: AsyncCallback | None = None + self._tasks: list[asyncio.Task] = [] + self._devices: list[InputDevice] = [] + + async def _handle_key_event(self, key_down: bool) -> None: + """Ruft den passenden Callback auf.""" + if key_down and self.on_press: + await self.on_press() + elif not key_down and self.on_release: + await self.on_release() + + async def _read_device(self, device: InputDevice) -> None: + """Liest Events von einem einzelnen Device.""" + async for event in device.async_read_loop(): + if event.type == ecodes.EV_KEY and event.code == self.key_code: + if event.value == 1: + logger.debug("%s gedrückt (via %s)", self.key_name, device.path) + await self._handle_key_event(key_down=True) + elif event.value == 0: + logger.debug("%s losgelassen (via %s)", self.key_name, device.path) + await self._handle_key_event(key_down=False) + + def stop(self) -> None: + """Cancelt laufende Read-Tasks und schließt Devices.""" + for task in self._tasks: + task.cancel() + for dev in self._devices: + try: + dev.close() + except Exception: + pass + self._tasks = [] + self._devices = [] + + async def listen(self) -> None: + """Lauscht auf evdev-Events der konfigurierten Taste auf allen passenden Devices.""" + self._devices = find_keyboard_devices(self.key_name) + for dev in self._devices: + logger.info("Lausche auf %s auf %s (%s)", self.key_name, dev.name, dev.path) + self._tasks = [asyncio.create_task(self._read_device(dev)) for dev in self._devices] + await asyncio.gather(*self._tasks, return_exceptions=True) +``` + +- [ ] **Step 4: Tests ausführen — erwartet PASS** + +Run: `uv run pytest tests/test_hotkey.py -v` +Expected: Alle Linux-Tests grün. Die bestehenden Tests (`test_init_stores_key_name`, `test_key_down_calls_callback`, ...) bleiben unverändert grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/hotkey/_evdev.py tests/test_hotkey.py +git commit -m "fix: EvdevHotkeyListener.stop() cancelt Tasks und schließt Devices" +``` + +--- + +## Task 5: `create_tray()`-Dispatch für Linux erweitern + +**Files:** +- Modify: `whisper_local/tray/__init__.py` +- Modify: `tests/test_tray.py` + +- [ ] **Step 1: Failing Test schreiben** + +Ersetze in `tests/test_tray.py` den bisherigen `test_returns_noop_tray_on_non_windows` (Zeile 257-264) und füge einen Linux-Test hinzu: + +```python +class TestCreateTray: + @pytest.mark.skipif(sys.platform != "win32", reason="Win32 nur auf Windows") + def test_returns_pystray_on_windows(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import PystrayApp + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, PystrayApp) + + @pytest.mark.skipif(sys.platform != "linux", reason="Linux-Tray nur auf Linux") + def test_returns_pystray_on_linux(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import PystrayApp + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, PystrayApp) + + def test_appstate_exported_from_package(self): + from whisper_local.tray import AppState + assert AppState.WAITING is not None + assert AppState.RECORDING is not None + assert AppState.TRANSCRIBING is not None +``` + +- [ ] **Step 2: Test ausführen — erwartet FAIL** + +Run: `uv run pytest tests/test_tray.py::TestCreateTray::test_returns_pystray_on_linux -v` +Expected: FAIL — `create_tray()` liefert auf Linux noch `NoOpTray`. + +- [ ] **Step 3: Dispatch erweitern** + +Ersetze [whisper_local/tray/__init__.py](whisper_local/tray/__init__.py): + +```python +"""Tray-Package — plattformspezifische Tray-App.""" + +import sys +from typing import Callable + +from whisper_local.tray._tray import AppState, NoOpTray + + +def create_tray( + on_settings: Callable[[], None], + on_quit: Callable[[], None], +) -> "PystrayApp | NoOpTray": + """Gibt den plattformspezifischen Tray zurück.""" + 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() + + +__all__ = ["create_tray", "AppState"] +``` + +- [ ] **Step 4: Tests ausführen — erwartet PASS** + +Run: `uv run pytest tests/test_tray.py -v` +Expected: Alle Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add whisper_local/tray/__init__.py tests/test_tray.py +git commit -m "feat: create_tray() dispatcht auf Linux zu PystrayApp" +``` + +--- + +## Task 6: `pyproject.toml` — Dependencies für Linux freigeben + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Marker entfernen** + +Ersetze die Dependencies-Liste in [pyproject.toml:5-16](pyproject.toml#L5-L16): + +```toml +dependencies = [ + "faster-whisper>=1.1.0", + "sounddevice>=0.5.0", + "numpy>=2.0.0", + "evdev>=1.7.0; sys_platform == 'linux'", + "pynput>=1.7.0; sys_platform == 'win32'", + "pywin32>=306; sys_platform == 'win32'", + "pystray>=0.19.0", + "Pillow>=10.0.0", + "sv-ttk>=2.6.0", + "darkdetect>=0.8.0", +] +``` + +- [ ] **Step 2: `uv sync` ausführen** + +Run: `uv sync` +Expected: Lock-File wird aktualisiert, alle Pakete installieren sauber. + +- [ ] **Step 3: Smoke-Import** + +Run: `uv run python -c "import pystray, PIL, sv_ttk, darkdetect; print('ok')"` +Expected: `ok` ohne Traceback. + +Hinweis: Falls `pystray` auf dem System einen Backend-Fehler wirft (fehlendes `libayatana-appindicator`), installiere `libayatana-appindicator` via Paketmanager. Der Import selbst sollte aber funktionieren — der AppIndicator wird erst zur Laufzeit beim `icon.run()` benötigt. + +- [ ] **Step 4: Gesamte Test-Suite** + +Run: `uv run pytest -v` +Expected: Alle Tests grün. + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "build: pystray/Pillow/sv-ttk/darkdetect für Linux freigeben" +``` + +--- + +## Task 7: Manueller Smoke-Test + +Kein Code-Change — verifiziert, dass App unter Linux tatsächlich startet, Tray sichtbar ist und Settings-Dialog funktioniert. + +- [ ] **Step 1: App starten** + +Run: `uv run whisper-local` +Expected: App startet, Log zeigt `whisper-local gestartet, warte auf Hotkey...`, Tray-Icon (graues Mikrofon) erscheint in der KDE-Taskleiste. + +- [ ] **Step 2: Hotkey-Roundtrip** + +Drücke `F12` (oder den konfigurierten Hotkey) kurz und sprich etwas. Lasse los. +Expected: Tray-Icon wird rot (Recording), dann gelb (Transcribing), dann wieder grau. Transkribierter Text wird eingefügt. + +- [ ] **Step 3: Settings-Dialog** + +Rechtsklick auf Tray → „Einstellungen". Klicke „Aufzeichnen" und drücke eine beliebige Taste. +Expected: Der neue Key erscheint im Label. Klicke „Speichern". Dialog schließt, App läuft weiter, neuer Hotkey funktioniert. + +- [ ] **Step 4: Beenden** + +Rechtsklick auf Tray → „Beenden". +Expected: App beendet sich sauber, kein Zombie-Prozess (`pgrep -af whisper-local` liefert nichts). + +- [ ] **Step 5: Ergebnis dokumentieren** + +Falls alle Schritte funktionieren: Feature ist fertig. Falls nicht, Befund notieren und als neuen Task aufnehmen. + +--- + +## Selbst-Review-Hinweise für den ausführenden Agent + +- **Backend-Check auf CachyOS:** Falls pystray beim `icon.run()` einen Fehler wie `ValueError: Unable to find a tray icon backend` wirft, fehlt AppIndicator. Installiere: `sudo pacman -S libayatana-appindicator` +- **evdev-Permissions:** Wenn `find_all_keyboards()` keine Devices zurückliefert, prüfe Gruppenmitgliedschaft: `groups | grep input`. Nutzer muss in Gruppe `input` sein. +- **Task 3 Test-Strategie:** Wenn der naive Mock-Ansatz für `selectors.DefaultSelector` nicht reicht, nutze den Selector-Patch aus Task 3 Step 4. +- **Keine Änderung an `__main__.py` nötig** — die `create_tray()`-API bleibt gleich, und der bestehende `_restart_hotkey()` funktioniert mit dem `stop()`-Fix aus Task 4 korrekt auf Linux. 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. diff --git a/pyproject.toml b/pyproject.toml index 3d4abf6..f6e40ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,12 +7,13 @@ dependencies = [ "sounddevice>=0.5.0", "numpy>=2.0.0", "evdev>=1.7.0; sys_platform == 'linux'", + "PyGObject>=3.50; sys_platform == 'linux'", "pynput>=1.7.0; sys_platform == 'win32'", "pywin32>=306; sys_platform == 'win32'", - "pystray>=0.19.0; sys_platform == 'win32'", - "Pillow>=10.0.0; sys_platform == 'win32'", - "sv-ttk>=2.6.0; sys_platform == 'win32'", - "darkdetect>=0.8.0; sys_platform == 'win32'", + "pystray>=0.19.0", + "Pillow>=10.0.0", + "sv-ttk>=2.6.0", + "darkdetect>=0.8.0", ] [project.scripts] diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index b89391c..dbbf214 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -127,3 +127,36 @@ class TestPynputHotkeyListenerStop: await asyncio.sleep(0) # Loop einen Schritt weiter listener.stop() await asyncio.wait_for(listen_task, timeout=1.0) + + +@pytest.mark.skipif(sys.platform != "linux", reason="evdev nur auf Linux") +class TestEvdevListenerStop: + @pytest.mark.asyncio + async def test_stop_cancels_tasks_and_closes_devices(self): + from whisper_local.hotkey._evdev import EvdevHotkeyListener + + listener = EvdevHotkeyListener("KEY_F12") + + async def never_ending(device): + while True: + await asyncio.sleep(1) + + fake_device = MagicMock() + fake_device.close = MagicMock() + + with patch( + "whisper_local.hotkey._evdev.find_keyboard_devices", + return_value=[fake_device], + ): + listener._read_device = never_ending + listen_task = asyncio.create_task(listener.listen()) + await asyncio.sleep(0.05) # Loop-Schritt, damit listen() startet + + assert len(listener._tasks) == 1 + listener.stop() + await asyncio.sleep(0.05) + + assert listener._tasks == [] + fake_device.close.assert_called_once() + + await asyncio.wait_for(listen_task, timeout=1.0) diff --git a/tests/test_tray.py b/tests/test_tray.py index 454293d..9d1b929 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -25,12 +25,12 @@ class TestCreateIcon: @pytest.mark.skipif(sys.platform != "win32", reason="Tray nur auf Windows") -class TestWin32TrayApp: +class TestPystrayApp: def test_set_state_updates_icon(self): from unittest.mock import MagicMock, patch - from whisper_local.tray._tray import AppState, Win32TrayApp + from whisper_local.tray._tray import AppState, PystrayApp - app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock()) + app = PystrayApp(on_settings=MagicMock(), on_quit=MagicMock()) mock_icon = MagicMock() app._icon = mock_icon @@ -41,9 +41,9 @@ class TestWin32TrayApp: def test_set_state_before_start_is_safe(self): from unittest.mock import MagicMock - from whisper_local.tray._tray import AppState, Win32TrayApp + from whisper_local.tray._tray import AppState, PystrayApp - app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock()) + app = PystrayApp(on_settings=MagicMock(), on_quit=MagicMock()) app.set_state(AppState.WAITING) # kein Fehler, _icon ist None @@ -111,7 +111,7 @@ class TestApplySystemTheme: class TestCheckHotkeyConflict: def test_returns_false_when_key_is_free(self): from unittest.mock import patch, MagicMock - from whisper_local.tray._settings import check_hotkey_conflict + from whisper_local.tray._hotkey_record_pynput import check_hotkey_conflict mock_user32 = MagicMock() mock_user32.RegisterHotKey.return_value = 1 # Erfolg @@ -123,7 +123,7 @@ class TestCheckHotkeyConflict: def test_returns_true_when_key_is_taken(self): from unittest.mock import patch, MagicMock - from whisper_local.tray._settings import check_hotkey_conflict + from whisper_local.tray._hotkey_record_pynput import check_hotkey_conflict mock_user32 = MagicMock() mock_user32.RegisterHotKey.return_value = 0 # Belegt @@ -134,7 +134,7 @@ class TestCheckHotkeyConflict: mock_user32.UnregisterHotKey.assert_not_called() def test_returns_false_for_unknown_key(self): - from whisper_local.tray._settings import check_hotkey_conflict + from whisper_local.tray._hotkey_record_pynput import check_hotkey_conflict result = check_hotkey_conflict("KEY_NONEXISTENT_999") assert result is False @@ -165,22 +165,22 @@ class TestListMicrophones: class TestPynputToEvdevKey: def test_function_key(self): from pynput.keyboard import Key - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key assert pynput_to_evdev_key(Key.f12) == "KEY_F12" def test_space_key(self): from pynput.keyboard import Key - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key assert pynput_to_evdev_key(Key.space) == "KEY_SPACE" def test_char_key(self): from pynput.keyboard import KeyCode - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key key = KeyCode.from_char("a") assert pynput_to_evdev_key(key) == "KEY_A" def test_unknown_returns_empty(self): - from whisper_local.tray._settings import pynput_to_evdev_key + from whisper_local.tray._hotkey_record_pynput import pynput_to_evdev_key assert pynput_to_evdev_key(None) == "" @@ -245,17 +245,29 @@ class TestSettingsDialog: class TestCreateTray: - @pytest.mark.skipif(sys.platform != "win32", reason="Win32TrayApp nur auf Windows") - def test_returns_win32_tray_on_windows(self): + @pytest.mark.skipif(sys.platform != "win32", reason="PystrayApp nur auf Windows") + def test_returns_pystray_on_windows(self): from unittest.mock import MagicMock from whisper_local.tray import create_tray - from whisper_local.tray._tray import Win32TrayApp + from whisper_local.tray._tray import PystrayApp tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) - assert isinstance(tray, Win32TrayApp) + assert isinstance(tray, PystrayApp) - @pytest.mark.skipif(sys.platform == "win32", reason="NoOpTray nur auf nicht-Windows") - def test_returns_noop_tray_on_non_windows(self): + @pytest.mark.skipif(sys.platform != "linux", reason="Linux-only") + def test_returns_pystray_on_linux(self): + from unittest.mock import MagicMock + from whisper_local.tray import create_tray + from whisper_local.tray._tray import PystrayApp + + tray = create_tray(on_settings=MagicMock(), on_quit=MagicMock()) + assert isinstance(tray, PystrayApp) + + @pytest.mark.skipif( + sys.platform in ("win32", "linux"), + reason="NoOpTray nur auf Plattformen ohne Tray-Unterstützung", + ) + def test_returns_noop_tray_on_unsupported_platform(self): from unittest.mock import MagicMock from whisper_local.tray import create_tray from whisper_local.tray._tray import NoOpTray @@ -268,3 +280,148 @@ class TestCreateTray: assert AppState.WAITING is not None assert AppState.RECORDING is not None assert AppState.TRANSCRIBING is not None + + +@pytest.mark.skipif(sys.platform != "linux", reason="evdev-Record nur auf Linux") +class TestRecordHotkeyEvdev: + def test_first_keydown_triggers_on_result(self): + import threading + from unittest.mock import MagicMock, patch + + fake_event = MagicMock() + fake_event.type = 1 # EV_KEY + fake_event.code = 88 # KEY_F12 + fake_event.value = 1 # Keydown + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([fake_event]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + def on_result(name, conflict): + results.append((name, conflict)) + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(on_result, cancel) + + assert results == [("KEY_F12", False)] + fake_device.close.assert_called_once() + + def test_cancel_event_stops_recording(self): + import threading + from unittest.mock import MagicMock, patch + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([]) + fake_device.close = MagicMock() + + cancel = threading.Event() + cancel.set() + + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + mock_sel.select.return_value = [] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [] + fake_device.close.assert_called_once() + + def test_ignores_key_up_events(self): + import threading + from unittest.mock import MagicMock, patch + + key_up = MagicMock() + key_up.type = 1 + key_up.code = 88 + key_up.value = 0 + + key_down = MagicMock() + key_down.type = 1 + key_down.code = 88 + key_down.value = 1 + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([key_up, key_down]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [("KEY_F12", False)] + + def test_skips_unknown_keycodes(self): + import threading + from unittest.mock import MagicMock, patch + + unknown_event = MagicMock() + unknown_event.type = 1 # EV_KEY + unknown_event.code = 9999 # unbekannter Keycode + unknown_event.value = 1 # Keydown + + known_event = MagicMock() + known_event.type = 1 # EV_KEY + known_event.code = 88 # KEY_F12 + known_event.value = 1 # Keydown + + fake_device = MagicMock() + fake_device.fd = 42 + fake_device.read.return_value = iter([unknown_event, known_event]) + fake_device.close = MagicMock() + + cancel = threading.Event() + results: list[tuple[str, bool]] = [] + + with patch( + "whisper_local.tray._hotkey_record_evdev.find_all_keyboards", + return_value=[fake_device], + ), patch( + "whisper_local.tray._hotkey_record_evdev.selectors.DefaultSelector" + ) as mock_sel_cls: + mock_sel = MagicMock() + mock_sel_cls.return_value = mock_sel + sel_key = MagicMock() + sel_key.data = fake_device + mock_sel.select.return_value = [(sel_key, None)] + from whisper_local.tray._hotkey_record_evdev import record_hotkey + record_hotkey(lambda n, c: results.append((n, c)), cancel) + + assert results == [("KEY_F12", False)] diff --git a/uv.lock b/uv.lock index 0a23f9c..6c6a417 100644 --- a/uv.lock +++ b/uv.lock @@ -469,18 +469,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, @@ -510,6 +542,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, ] +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } + [[package]] name = "pycparser" version = "3.0" @@ -528,6 +566,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pygobject" +version = "3.56.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" } + [[package]] name = "pyinstaller" version = "6.19.0" @@ -575,6 +622,7 @@ version = "1.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, { name = "python-xlib", marker = "'linux' in sys_platform" }, { name = "six" }, ] @@ -583,12 +631,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/4f/ac3fa906ae8a375a536b12794128c5efacade9eaa917a35dfd27ce0c7400/pynput-1.8.1-py2.py3-none-any.whl", hash = "sha256:42dfcf27404459ca16ca889c8fb8ffe42a9fe54f722fd1a3e130728e59e768d2", size = 91693, upload-time = "2025-03-17T17:12:00.094Z" }, ] +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/2d/e8f495328101898c16c32ac10e7b14b08ff2c443a756a76fd1271915f097/pyobjc_framework_quartz-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:629b7971b1b43a11617f1460cd218bd308dfea247cd4ee3842eb40ca6f588860", size = 219206, upload-time = "2025-11-14T10:00:15.623Z" }, + { url = "https://files.pythonhosted.org/packages/67/43/b1f0ad3b842ab150a7e6b7d97f6257eab6af241b4c7d14cb8e7fde9214b8/pyobjc_framework_quartz-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:53b84e880c358ba1ddcd7e8d5ea0407d760eca58b96f0d344829162cda5f37b3", size = 224317, upload-time = "2025-11-14T10:00:30.703Z" }, + { url = "https://files.pythonhosted.org/packages/4a/00/96249c5c7e5aaca5f688ca18b8d8ad05cd7886ebd639b3c71a6a4cadbe75/pyobjc_framework_quartz-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:42d306b07f05ae7d155984503e0fb1b701fecd31dcc5c79fe8ab9790ff7e0de0", size = 219558, upload-time = "2025-11-14T10:00:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a6/708a55f3ff7a18c403b30a29a11dccfed0410485a7548c60a4b6d4cc0676/pyobjc_framework_quartz-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0cc08fddb339b2760df60dea1057453557588908e42bdc62184b6396ce2d6e9a", size = 224580, upload-time = "2025-11-14T10:01:00.091Z" }, +] + [[package]] name = "pystray" version = "0.19.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, { name = "python-xlib", marker = "sys_platform == 'linux'" }, { name = "six" }, ] @@ -838,16 +930,17 @@ name = "whisper-local" version = "1.1.0" source = { editable = "." } dependencies = [ - { name = "darkdetect", marker = "sys_platform == 'win32'" }, + { name = "darkdetect" }, { name = "evdev", marker = "sys_platform == 'linux'" }, { name = "faster-whisper" }, { name = "numpy" }, - { name = "pillow", marker = "sys_platform == 'win32'" }, + { name = "pillow" }, + { name = "pygobject", marker = "sys_platform == 'linux'" }, { name = "pynput", marker = "sys_platform == 'win32'" }, - { name = "pystray", marker = "sys_platform == 'win32'" }, + { name = "pystray" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sounddevice" }, - { name = "sv-ttk", marker = "sys_platform == 'win32'" }, + { name = "sv-ttk" }, ] [package.dev-dependencies] @@ -861,16 +954,17 @@ dev = [ [package.metadata] requires-dist = [ - { name = "darkdetect", marker = "sys_platform == 'win32'", specifier = ">=0.8.0" }, + { name = "darkdetect", specifier = ">=0.8.0" }, { name = "evdev", marker = "sys_platform == 'linux'", specifier = ">=1.7.0" }, { name = "faster-whisper", specifier = ">=1.1.0" }, { name = "numpy", specifier = ">=2.0.0" }, - { name = "pillow", marker = "sys_platform == 'win32'", specifier = ">=10.0.0" }, + { name = "pillow", specifier = ">=10.0.0" }, + { name = "pygobject", marker = "sys_platform == 'linux'", specifier = ">=3.50" }, { name = "pynput", marker = "sys_platform == 'win32'", specifier = ">=1.7.0" }, - { name = "pystray", marker = "sys_platform == 'win32'", specifier = ">=0.19.0" }, + { name = "pystray", specifier = ">=0.19.0" }, { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=306" }, { name = "sounddevice", specifier = ">=0.5.0" }, - { name = "sv-ttk", marker = "sys_platform == 'win32'", specifier = ">=2.6.0" }, + { name = "sv-ttk", specifier = ">=2.6.0" }, ] [package.metadata.requires-dev] diff --git a/whisper_local/hotkey/_evdev.py b/whisper_local/hotkey/_evdev.py index e057d32..6ad87f8 100644 --- a/whisper_local/hotkey/_evdev.py +++ b/whisper_local/hotkey/_evdev.py @@ -37,6 +37,8 @@ class EvdevHotkeyListener: raise ValueError(f"Unbekannter Key-Name: {key_name}") self.on_press: AsyncCallback | None = None self.on_release: AsyncCallback | None = None + self._tasks: list[asyncio.Task] = [] + self._devices: list[InputDevice] = [] async def _handle_key_event(self, key_down: bool) -> None: """Ruft den passenden Callback auf.""" @@ -57,13 +59,21 @@ class EvdevHotkeyListener: await self._handle_key_event(key_down=False) def stop(self) -> None: - """Stub — evdev-Listener läuft bis zum Prozessende.""" - pass + """Cancelt laufende Read-Tasks und schließt Devices.""" + for task in self._tasks: + task.cancel() + for dev in self._devices: + try: + dev.close() + except Exception: + pass + self._tasks = [] + self._devices = [] async def listen(self) -> None: """Lauscht auf evdev-Events der konfigurierten Taste auf allen passenden Devices.""" - devices = find_keyboard_devices(self.key_name) - for dev in devices: + self._devices = find_keyboard_devices(self.key_name) + for dev in self._devices: logger.info("Lausche auf %s auf %s (%s)", self.key_name, dev.name, dev.path) - tasks = [asyncio.create_task(self._read_device(dev)) for dev in devices] - await asyncio.gather(*tasks) + self._tasks = [asyncio.create_task(self._read_device(dev)) for dev in self._devices] + await asyncio.gather(*self._tasks, return_exceptions=True) diff --git a/whisper_local/tray/__init__.py b/whisper_local/tray/__init__.py index 3e7be64..023f176 100644 --- a/whisper_local/tray/__init__.py +++ b/whisper_local/tray/__init__.py @@ -9,11 +9,11 @@ from whisper_local.tray._tray import AppState, NoOpTray def create_tray( on_settings: Callable[[], None], on_quit: Callable[[], None], -) -> "Win32TrayApp | NoOpTray": +) -> "PystrayApp | NoOpTray": """Gibt den plattformspezifischen Tray zurück.""" - if sys.platform == "win32": - from whisper_local.tray._tray import Win32TrayApp - return Win32TrayApp(on_settings=on_settings, on_quit=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() diff --git a/whisper_local/tray/_hotkey_record_evdev.py b/whisper_local/tray/_hotkey_record_evdev.py new file mode 100644 index 0000000..f24ee5a --- /dev/null +++ b/whisper_local/tray/_hotkey_record_evdev.py @@ -0,0 +1,82 @@ +"""Hotkey-Aufzeichnung via evdev (Linux).""" + +import selectors +import threading +from typing import Callable + +import evdev +from evdev import InputDevice, ecodes + + +def find_all_keyboards() -> list[InputDevice]: + """Gibt alle Input-Devices zurück, die EV_KEY-Events liefern können.""" + keyboards: list[InputDevice] = [] + try: + for path in evdev.list_devices(): + try: + device = InputDevice(path) + except (PermissionError, OSError): + continue + try: + capabilities = device.capabilities() + except OSError: + device.close() + continue + if ecodes.EV_KEY in capabilities: + keyboards.append(device) + else: + device.close() + except BaseException: + for dev in keyboards: + dev.close() + raise + return keyboards + + +def _keycode_to_name(code: int) -> str: + """Übersetzt evdev-Keycode zu Key-Namen. Gibt '' bei unbekanntem Code.""" + name = ecodes.KEY.get(code) + if isinstance(name, list): + return name[0] + if isinstance(name, str): + return name + return "" + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis zum ersten Keydown oder bis cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. has_conflict ist auf + Linux immer False — es gibt kein Äquivalent zum Win32-RegisterHotKey-Check. + """ + devices = find_all_keyboards() + if not devices: + return + + selector = selectors.DefaultSelector() + try: + for dev in devices: + selector.register(dev.fd, selectors.EVENT_READ, dev) + + captured: str | None = None + while captured is None and not cancel_event.is_set(): + for key, _mask in selector.select(timeout=0.1): + dev: InputDevice = key.data + for event in dev.read(): + if event.type == ecodes.EV_KEY and event.value == 1: + name = _keycode_to_name(event.code) + if name: + captured = name + break + if captured: + break + + if captured and not cancel_event.is_set(): + on_result(captured, False) + finally: + selector.close() + for dev in devices: + dev.close() diff --git a/whisper_local/tray/_hotkey_record_pynput.py b/whisper_local/tray/_hotkey_record_pynput.py new file mode 100644 index 0000000..4a1174e --- /dev/null +++ b/whisper_local/tray/_hotkey_record_pynput.py @@ -0,0 +1,70 @@ +"""Hotkey-Aufzeichnung und Konflikt-Erkennung für Windows (pynput + Win32).""" + +import ctypes +import threading +from typing import Callable + + +def pynput_to_evdev_key(key) -> str: + """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" + from pynput.keyboard import Key, KeyCode + + if isinstance(key, Key): + return f"KEY_{key.name.upper()}" + if isinstance(key, KeyCode) and key.char: + return f"KEY_{key.char.upper()}" + return "" + + +def check_hotkey_conflict(evdev_name: str) -> bool: + """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" + from whisper_local.hotkey._pynput import _evdev_to_pynput_key + from pynput.keyboard import Key + + try: + pynput_key = _evdev_to_pynput_key(evdev_name) + except ValueError: + return False + + if not isinstance(pynput_key, Key): + return False + + vk = getattr(pynput_key.value, "vk", None) + if vk is None: + return False + + HOTKEY_ID = 0x7FFF + user32 = ctypes.windll.user32 + if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): + user32.UnregisterHotKey(None, HOTKEY_ID) + return False + return True + + +def record_hotkey( + on_result: Callable[[str, bool], None], + cancel_event: threading.Event, +) -> None: + """Blockiert bis der nächste Keydown kommt oder cancel_event gesetzt wird. + + Ruft on_result(evdev_key_name, has_conflict) auf. + """ + from pynput.keyboard import Listener + + captured: list[str] = [] + + def on_press(key): + evdev = pynput_to_evdev_key(key) + if evdev: + captured.append(evdev) + return False # Listener stoppen + + with Listener(on_press=on_press) as lst: + while lst.running and not cancel_event.is_set(): + lst.join(timeout=0.1) + if lst.running: + lst.stop() + + if captured and not cancel_event.is_set(): + evdev = captured[0] + on_result(evdev, check_hotkey_conflict(evdev)) diff --git a/whisper_local/tray/_settings.py b/whisper_local/tray/_settings.py index bbe3608..b16714c 100644 --- a/whisper_local/tray/_settings.py +++ b/whisper_local/tray/_settings.py @@ -1,8 +1,8 @@ -"""Einstellungs-Dialog für whisper-local (Windows).""" +"""Einstellungs-Dialog für whisper-local (cross-platform).""" from __future__ import annotations -import ctypes +import sys import threading from typing import Callable @@ -12,42 +12,6 @@ from whisper_local.config import Config, save_config from whisper_local.tray._theme import apply_system_theme -def pynput_to_evdev_key(key) -> str: - """Konvertiert pynput-Key zu evdev-Key-Namen (z.B. Key.f12 → 'KEY_F12').""" - from pynput.keyboard import Key, KeyCode - - if isinstance(key, Key): - return f"KEY_{key.name.upper()}" - if isinstance(key, KeyCode) and key.char: - return f"KEY_{key.char.upper()}" - return "" - - -def check_hotkey_conflict(evdev_name: str) -> bool: - """Gibt True zurück wenn die Taste per Win32-RegisterHotKey belegt ist.""" - from whisper_local.hotkey._pynput import _evdev_to_pynput_key - from pynput.keyboard import Key - - try: - pynput_key = _evdev_to_pynput_key(evdev_name) - except ValueError: - return False - - if not isinstance(pynput_key, Key): - return False - - vk = getattr(pynput_key.value, "vk", None) - if vk is None: - return False - - HOTKEY_ID = 0x7FFF - user32 = ctypes.windll.user32 - if user32.RegisterHotKey(None, HOTKEY_ID, 0, vk): - user32.UnregisterHotKey(None, HOTKEY_ID) - return False - return True - - def list_microphones() -> list[tuple[str, int]]: """Gibt Liste aller Eingabegeräte als (name, index) zurück.""" devices = sd.query_devices() @@ -58,12 +22,22 @@ def list_microphones() -> list[tuple[str, int]]: ] +def _get_record_hotkey(): + """Wählt das plattformspezifische record_hotkey-Callable.""" + if sys.platform == "win32": + from whisper_local.tray._hotkey_record_pynput import record_hotkey + return record_hotkey + from whisper_local.tray._hotkey_record_evdev import record_hotkey + return record_hotkey + + class SettingsDialog: """Einstellungs-Dialog (läuft in eigenem Thread).""" def __init__(self, config: Config, on_save: Callable[[Config], None]): self._config = config self._on_save = on_save + self._cancel_event = threading.Event() def open(self) -> None: """Öffnet den Dialog in einem Daemon-Thread.""" @@ -94,36 +68,28 @@ class SettingsDialog: row=1, column=0, columnspan=3, sticky=tk.W ) - def record_hotkey(): + record_hotkey = _get_record_hotkey() + + def do_record(): hotkey_var.set("...") conflict_var.set("") - captured: list[str] = [] + def on_result(evdev_key: str, has_conflict: bool): + root.after(0, lambda: hotkey_var.set(evdev_key)) + if has_conflict: + root.after( + 0, + lambda: conflict_var.set( + "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" + ), + ) - def on_press(key): - evdev = pynput_to_evdev_key(key) - if evdev: - captured.append(evdev) - return False # Listener stoppen + def worker(): + record_hotkey(on_result, self._cancel_event) - def listen(): - from pynput.keyboard import Listener - with Listener(on_press=on_press) as lst: - lst.join() - if captured: - evdev = captured[0] - root.after(0, lambda: hotkey_var.set(evdev)) - if check_hotkey_conflict(evdev): - root.after( - 0, - lambda: conflict_var.set( - "⚠ Taste ist von einer anderen App belegt (Win32-Hotkeys)" - ), - ) + threading.Thread(target=worker, daemon=True).start() - threading.Thread(target=listen, daemon=True).start() - - ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).grid( + ttk.Button(frame, text="Aufzeichnen", command=do_record).grid( row=0, column=2, padx=4 ) @@ -154,9 +120,15 @@ class SettingsDialog: ) save_config(new_config) self._on_save(new_config) + self._cancel_event.set() + root.destroy() + + def cancel(): + self._cancel_event.set() root.destroy() ttk.Button(btn_frame, text="Speichern", command=save).pack(side=tk.RIGHT, padx=4) - ttk.Button(btn_frame, text="Abbrechen", command=root.destroy).pack(side=tk.RIGHT) + ttk.Button(btn_frame, text="Abbrechen", command=cancel).pack(side=tk.RIGHT) + root.protocol("WM_DELETE_WINDOW", cancel) root.mainloop() diff --git a/whisper_local/tray/_tray.py b/whisper_local/tray/_tray.py index ca85f0d..4ff255b 100644 --- a/whisper_local/tray/_tray.py +++ b/whisper_local/tray/_tray.py @@ -1,4 +1,4 @@ -"""Tray-App und App-Zustände für whisper-local (Windows).""" +"""Tray-App und App-Zustände für whisper-local.""" import enum import threading @@ -11,8 +11,8 @@ class AppState(enum.Enum): TRANSCRIBING = "transcribing" -class Win32TrayApp: - """Tray-Icon via pystray für Windows.""" +class PystrayApp: + """Tray-Icon via pystray — cross-platform (Windows + Linux).""" def __init__(self, on_settings: Callable[[], None], on_quit: Callable[[], None]): self._on_settings = on_settings