From 70d2b6d6e4be186a8cc5cbc56c970f1df1ab1684 Mon Sep 17 00:00:00 2001 From: Vitali Graf Date: Mon, 6 Apr 2026 20:28:18 +0200 Subject: [PATCH] feat: add inserter module with clipboard paste and restore Co-Authored-By: Claude Sonnet 4.6 --- tests/test_inserter.py | 69 +++++++++++++++++++++++++++++++++++++++ whisper_local/inserter.py | 45 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 tests/test_inserter.py create mode 100644 whisper_local/inserter.py diff --git a/tests/test_inserter.py b/tests/test_inserter.py new file mode 100644 index 0000000..2e9f43d --- /dev/null +++ b/tests/test_inserter.py @@ -0,0 +1,69 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch, call + +import pytest + +from whisper_local.inserter import Inserter + + +class TestInserter: + @pytest.mark.asyncio + @patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock) + @patch("whisper_local.inserter.asyncio.create_subprocess_exec") + async def test_insert_text_calls_tools_in_order(self, mock_exec, mock_sleep): + # Mock für alle subprocess-Aufrufe + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (b"alter clipboard", b"") + mock_proc.returncode = 0 + mock_exec.return_value = mock_proc + + inserter = Inserter() + await inserter.insert("Hallo Welt") + + # Prüfe dass wl-paste, wl-copy, wtype, wl-copy aufgerufen werden + calls = mock_exec.call_args_list + assert len(calls) == 4 + assert calls[0][0][0] == "wl-paste" # Clipboard sichern + assert calls[1][0] == ("wl-copy", "Hallo Welt") # Text in Clipboard + assert calls[2][0][0] == "wtype" # Ctrl+V simulieren + assert calls[3][0] == ("wl-copy", "alter clipboard") # Clipboard wiederherstellen + + @pytest.mark.asyncio + @patch("whisper_local.inserter.asyncio.create_subprocess_exec") + async def test_insert_empty_text_does_nothing(self, mock_exec): + inserter = Inserter() + await inserter.insert("") + + mock_exec.assert_not_called() + + @pytest.mark.asyncio + @patch("whisper_local.inserter.asyncio.sleep", new_callable=AsyncMock) + @patch("whisper_local.inserter.asyncio.create_subprocess_exec") + async def test_clipboard_restore_on_paste_failure(self, mock_exec, mock_sleep): + call_count = 0 + + async def mock_create_proc(*args, **kwargs): + nonlocal call_count + call_count += 1 + mock_proc = AsyncMock() + if call_count == 1: # wl-paste + mock_proc.communicate.return_value = (b"original", b"") + mock_proc.returncode = 0 + elif call_count == 2: # wl-copy + mock_proc.communicate.return_value = (b"", b"") + mock_proc.returncode = 0 + elif call_count == 3: # wtype — fails + mock_proc.communicate.return_value = (b"", b"error") + mock_proc.returncode = 1 + else: # wl-copy restore + mock_proc.communicate.return_value = (b"", b"") + mock_proc.returncode = 0 + return mock_proc + + mock_exec.side_effect = mock_create_proc + + inserter = Inserter() + await inserter.insert("Test") + + # Clipboard muss trotzdem wiederhergestellt werden + assert call_count == 4 diff --git a/whisper_local/inserter.py b/whisper_local/inserter.py new file mode 100644 index 0000000..d73c6a0 --- /dev/null +++ b/whisper_local/inserter.py @@ -0,0 +1,45 @@ +"""Text-Einfügung via Clipboard und wtype.""" + +import asyncio +import logging + +logger = logging.getLogger(__name__) + +PASTE_DELAY = 0.2 # Sekunden Wartezeit vor Clipboard-Restore + + +class Inserter: + async def _run(self, *cmd: str) -> tuple[bytes, int]: + """Führt einen Befehl aus, gibt (stdout, returncode) zurück.""" + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + logger.warning("Befehl fehlgeschlagen: %s (stderr: %s)", cmd, stderr.decode()) + return stdout, proc.returncode + + async def insert(self, text: str) -> None: + """Fügt Text ins aktive Fenster ein via Clipboard + Ctrl+V.""" + if not text: + return + + # 1. Aktuelle Zwischenablage sichern + old_clipboard, _ = await self._run("wl-paste") + + try: + # 2. Text in Zwischenablage kopieren + await self._run("wl-copy", text) + + # 3. Ctrl+V simulieren + await self._run("wtype", "-M", "ctrl", "v", "-m", "ctrl") + + # 4. Kurz warten, dann Clipboard wiederherstellen + await asyncio.sleep(PASTE_DELAY) + finally: + # Clipboard immer wiederherstellen + await self._run("wl-copy", old_clipboard.decode()) + + logger.info("Text eingefügt (%d Zeichen)", len(text))