From 9a1b96d178bb5989adc1a6cff0ebaab7e1e3ed9f Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Fri, 10 Apr 2026 21:09:10 +0200 Subject: [PATCH] feat: add Win32TrayApp and NoOpTray with state management --- tests/test_tray.py | 35 ++++++++++++++++++++++++ whisper_local/tray/_tray.py | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/tests/test_tray.py b/tests/test_tray.py index eaa50ee..3604278 100644 --- a/tests/test_tray.py +++ b/tests/test_tray.py @@ -22,3 +22,38 @@ class TestCreateIcon: waiting = create_icon(AppState.WAITING) recording = create_icon(AppState.RECORDING) assert waiting.tobytes() != recording.tobytes() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Tray nur auf Windows") +class TestWin32TrayApp: + def test_set_state_updates_icon(self): + from unittest.mock import MagicMock, patch + from whisper_local.tray._tray import AppState, Win32TrayApp + + app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock()) + + mock_icon = MagicMock() + app._icon = mock_icon + + app.set_state(AppState.RECORDING) + + assert mock_icon.icon is not None + + def test_set_state_before_start_is_safe(self): + from unittest.mock import MagicMock + from whisper_local.tray._tray import AppState, Win32TrayApp + + app = Win32TrayApp(on_settings=MagicMock(), on_quit=MagicMock()) + app.set_state(AppState.WAITING) # kein Fehler, _icon ist None + + +class TestNoOpTray: + def test_start_does_nothing(self): + from whisper_local.tray._tray import AppState, NoOpTray + tray = NoOpTray() + tray.start() # kein Fehler + + def test_set_state_does_nothing(self): + from whisper_local.tray._tray import AppState, NoOpTray + tray = NoOpTray() + tray.set_state(AppState.RECORDING) # kein Fehler diff --git a/whisper_local/tray/_tray.py b/whisper_local/tray/_tray.py index 1959ebb..ca85f0d 100644 --- a/whisper_local/tray/_tray.py +++ b/whisper_local/tray/_tray.py @@ -1,9 +1,62 @@ """Tray-App und App-Zustände für whisper-local (Windows).""" import enum +import threading +from typing import Callable class AppState(enum.Enum): WAITING = "waiting" RECORDING = "recording" TRANSCRIBING = "transcribing" + + +class Win32TrayApp: + """Tray-Icon via pystray für Windows.""" + + 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() + + +class NoOpTray: + """Platzhalter-Implementierung für nicht-Windows-Plattformen.""" + + def start(self) -> None: + pass + + def set_state(self, state: AppState) -> None: + pass