From fc96e9a10cd240dea2c5fc6ecc915f0d4c7bd037 Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Wed, 8 Apr 2026 10:36:38 +0200 Subject: [PATCH] feat: add Win32 inserter backend with clipboard + Ctrl+V Co-Authored-By: Claude Sonnet 4.6 --- tests/test_inserter.py | 42 ++++++++++++++++++++++ whisper_local/inserter/_win32.py | 61 ++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 whisper_local/inserter/_win32.py diff --git a/tests/test_inserter.py b/tests/test_inserter.py index 75d08e9..5047e57 100644 --- a/tests/test_inserter.py +++ b/tests/test_inserter.py @@ -60,3 +60,45 @@ class TestWaylandInserter: await inserter.insert("Test") assert call_count == 4 + + +class TestWin32Inserter: + @pytest.mark.asyncio + @patch("whisper_local.inserter._win32.keyboard_controller") + @patch("whisper_local.inserter._win32.Win32Inserter._get_clipboard", return_value="alter clipboard") + @patch("whisper_local.inserter._win32.Win32Inserter._set_clipboard") + async def test_insert_text(self, mock_set, mock_get, mock_kb): + from whisper_local.inserter._win32 import Win32Inserter + + inserter = Win32Inserter() + await inserter.insert("Hallo Welt") + + # Clipboard wurde gesichert + mock_get.assert_called_once() + # Text wurde in Clipboard gesetzt, dann Ctrl+V, dann Restore + assert mock_set.call_count == 2 + mock_set.assert_any_call("Hallo Welt") + mock_set.assert_any_call("alter clipboard") + + @pytest.mark.asyncio + async def test_insert_empty_does_nothing(self): + from whisper_local.inserter._win32 import Win32Inserter + + inserter = Win32Inserter() + await inserter.insert("") + + @pytest.mark.asyncio + @patch("whisper_local.inserter._win32.keyboard_controller") + @patch("whisper_local.inserter._win32.Win32Inserter._get_clipboard", return_value="original") + @patch("whisper_local.inserter._win32.Win32Inserter._set_clipboard") + async def test_clipboard_restored_on_error(self, mock_set, mock_get, mock_kb): + from whisper_local.inserter._win32 import Win32Inserter + + mock_kb.press.side_effect = OSError("keyboard error") + + inserter = Win32Inserter() + with pytest.raises(OSError): + await inserter.insert("Test") + + # Clipboard muss trotzdem wiederhergestellt werden + mock_set.assert_called_with("original") diff --git a/whisper_local/inserter/_win32.py b/whisper_local/inserter/_win32.py new file mode 100644 index 0000000..8ddb7a8 --- /dev/null +++ b/whisper_local/inserter/_win32.py @@ -0,0 +1,61 @@ +"""Text-Einfügung via pynput Clipboard + Ctrl+V (Windows).""" + +import asyncio +import logging + +from pynput.keyboard import Controller, Key + +logger = logging.getLogger(__name__) + +keyboard_controller = Controller() + +PASTE_DELAY = 0.2 + + +class Win32Inserter: + def _get_clipboard(self) -> str: + """Liest den aktuellen Clipboard-Inhalt (Text).""" + import win32clipboard + try: + win32clipboard.OpenClipboard() + try: + data = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT) + return data + except TypeError: + return "" + finally: + win32clipboard.CloseClipboard() + except Exception: + return "" + + def _set_clipboard(self, text: str) -> None: + """Setzt den Clipboard-Inhalt.""" + import win32clipboard + win32clipboard.OpenClipboard() + try: + win32clipboard.EmptyClipboard() + win32clipboard.SetClipboardText(text, win32clipboard.CF_UNICODETEXT) + finally: + win32clipboard.CloseClipboard() + + async def insert(self, text: str) -> None: + """Fügt Text ins aktive Fenster ein via Clipboard + Ctrl+V.""" + if not text: + return + + old_clipboard = await asyncio.to_thread(self._get_clipboard) + + try: + await asyncio.to_thread(self._set_clipboard, text) + await asyncio.sleep(0.05) + + keyboard_controller.press(Key.ctrl) + keyboard_controller.press('v') + keyboard_controller.release('v') + keyboard_controller.release(Key.ctrl) + + await asyncio.sleep(PASTE_DELAY) + finally: + await asyncio.to_thread(self._set_clipboard, old_clipboard) + + logger.info("Text eingefügt (%d Zeichen)", len(text))