diff --git a/tests/test_tray.py b/tests/test_tray.py index df06495..ab93aeb 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -182,3 +182,63 @@ class TestPynputToEvdevKey: def test_unknown_returns_empty(self): from whisper_local.tray._settings import pynput_to_evdev_key assert pynput_to_evdev_key(None) == "" + + +@pytest.mark.skipif(sys.platform != "win32", reason="Settings nur auf Windows") +class TestSettingsDialog: + def test_on_save_called_with_new_config(self): + import tkinter as tk + from unittest.mock import patch, MagicMock, call + from whisper_local.config import Config + from whisper_local.tray._settings import SettingsDialog + + saved = [] + dialog = SettingsDialog( + config=Config(hotkey="KEY_F12", microphone=""), + on_save=saved.append, + ) + + # Dialog direkt aufrufen (nicht in Thread), mit gemocktem mainloop + with patch("tkinter.Tk.mainloop"), \ + patch("whisper_local.tray._settings.apply_system_theme"), \ + patch("whisper_local.tray._settings.list_microphones", return_value=[]), \ + patch("whisper_local.tray._settings.save_config") as mock_save: + dialog._run() + + # _run() ruft mainloop auf, der sofort zurückkehrt. + # save() wird nicht automatisch aufgerufen — das ist korrekt. + # Wir prüfen nur, dass _run() ohne Fehler durchläuft. + assert mock_save.call_count == 0 # Noch nicht gespeichert ohne Klick + + def test_on_save_callback_called_when_save_invoked(self): + import tkinter as tk + from unittest.mock import patch, MagicMock + from whisper_local.config import Config + from whisper_local.tray._settings import SettingsDialog + + saved_configs = [] + dialog = SettingsDialog( + config=Config(hotkey="KEY_F12", microphone=""), + on_save=saved_configs.append, + ) + + # _run() intern aufrufen und save() direkt triggern + captured_save_fn = [] + + def fake_button(frame, text, command, **kwargs): + if text == "Speichern": + captured_save_fn.append(command) + mock = MagicMock() + mock.pack = MagicMock() + return mock + + with patch("tkinter.Tk.mainloop"), \ + patch("whisper_local.tray._settings.apply_system_theme"), \ + patch("whisper_local.tray._settings.list_microphones", return_value=[("USB Mic", 0)]), \ + patch("whisper_local.tray._settings.save_config"), \ + patch("tkinter.ttk.Button", side_effect=fake_button): + dialog._run() + + if captured_save_fn: + captured_save_fn[0]() + assert len(saved_configs) == 1 diff --git a/whisper_local/tray/_settings.py b/whisper_local/tray/_settings.py index 7c54168..bbe3608 100644 --- a/whisper_local/tray/_settings.py +++ b/whisper_local/tray/_settings.py @@ -9,6 +9,7 @@ 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 pynput_to_evdev_key(key) -> str: @@ -55,3 +56,107 @@ def list_microphones() -> list[tuple[str, int]]: for idx, dev in enumerate(devices) if dev["max_input_channels"] > 0 ] + + +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 + + 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 + ) + + def record_hotkey(): + hotkey_var.set("...") + conflict_var.set("") + + captured: list[str] = [] + + def on_press(key): + evdev = pynput_to_evdev_key(key) + if evdev: + captured.append(evdev) + return False # Listener stoppen + + 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=listen, daemon=True).start() + + ttk.Button(frame, text="Aufzeichnen", command=record_hotkey).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) + 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) + + root.mainloop()