Merge branch 'master' of https://code.vitaligraf.de/info/whisper-local
This commit is contained in:
@@ -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 <repo-url> 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+5
-4
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
+175
-18
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user