From ccf968a410bef7d2f4c462ee3ebdf91ab198f290 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Wed, 25 Feb 2026 10:56:32 -0300 Subject: [PATCH] Add clipboard cleanup option for clipboard backend --- README.md | 6 +++++- config.example.json | 3 ++- src/config.py | 8 ++++++++ src/desktop.py | 8 +++++++- src/desktop_wayland.py | 9 ++++++++- src/desktop_x11.py | 30 +++++++++++++++++++++++++++++- src/leld.py | 8 +++++++- tests/test_config.py | 17 ++++++++++++++++- tests/test_leld.py | 36 ++++++++++++++++++++++++++++++++---- 9 files changed, 114 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 5b415a2..343e56d 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,10 @@ Create `~/.config/lel/config.json`: "daemon": { "hotkey": "Cmd+m" }, "recording": { "input": "0" }, "stt": { "model": "base", "device": "cpu" }, - "injection": { "backend": "clipboard" }, + "injection": { + "backend": "clipboard", + "remove_transcription_from_clipboard": false + }, "ai": { "enabled": true }, "vocabulary": { "replacements": [ @@ -161,6 +164,7 @@ Injection backends: - `clipboard`: copy to clipboard and inject via Ctrl+Shift+V (GTK clipboard + XTest) - `injection`: type the text with simulated keypresses (XTest) +- `injection.remove_transcription_from_clipboard`: when `true` and backend is `clipboard`, restores/clears the clipboard after paste so the transcript is not kept there AI processing: diff --git a/config.example.json b/config.example.json index 8a9a780..6c1dc63 100644 --- a/config.example.json +++ b/config.example.json @@ -10,7 +10,8 @@ "device": "cpu" }, "injection": { - "backend": "clipboard" + "backend": "clipboard", + "remove_transcription_from_clipboard": false }, "ai": { "enabled": true diff --git a/src/config.py b/src/config.py index 6099375..2b2299c 100644 --- a/src/config.py +++ b/src/config.py @@ -38,6 +38,7 @@ class SttConfig: @dataclass class InjectionConfig: backend: str = DEFAULT_INJECTION_BACKEND + remove_transcription_from_clipboard: bool = False @dataclass @@ -115,6 +116,8 @@ def validate(cfg: Config) -> None: allowed = ", ".join(sorted(ALLOWED_INJECTION_BACKENDS)) raise ValueError(f"injection.backend must be one of: {allowed}") cfg.injection.backend = backend + if not isinstance(cfg.injection.remove_transcription_from_clipboard, bool): + raise ValueError("injection.remove_transcription_from_clipboard must be boolean") if not isinstance(cfg.ai.enabled, bool): raise ValueError("ai.enabled must be boolean") @@ -180,6 +183,11 @@ def _from_dict(data: dict[str, Any], cfg: Config) -> Config: cfg.stt.device = _as_nonempty_str(stt["device"], "stt.device") if "backend" in injection: cfg.injection.backend = _as_nonempty_str(injection["backend"], "injection.backend") + if "remove_transcription_from_clipboard" in injection: + cfg.injection.remove_transcription_from_clipboard = _as_bool( + injection["remove_transcription_from_clipboard"], + "injection.remove_transcription_from_clipboard", + ) if "enabled" in ai: cfg.ai.enabled = _as_bool(ai["enabled"], "ai.enabled") if "replacements" in vocabulary: diff --git a/src/desktop.py b/src/desktop.py index 3c89bb6..40c885d 100644 --- a/src/desktop.py +++ b/src/desktop.py @@ -11,7 +11,13 @@ class DesktopAdapter(Protocol): def start_cancel_listener(self, callback: Callable[[], None]) -> None: raise NotImplementedError - def inject_text(self, text: str, backend: str) -> None: + def inject_text( + self, + text: str, + backend: str, + *, + remove_transcription_from_clipboard: bool = False, + ) -> None: raise NotImplementedError def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None: diff --git a/src/desktop_wayland.py b/src/desktop_wayland.py index 6d3c6c5..1da88a8 100644 --- a/src/desktop_wayland.py +++ b/src/desktop_wayland.py @@ -10,7 +10,14 @@ class WaylandAdapter: def start_cancel_listener(self, _callback: Callable[[], None]) -> None: raise SystemExit("Wayland hotkeys are not supported yet.") - def inject_text(self, _text: str, _backend: str) -> None: + def inject_text( + self, + _text: str, + _backend: str, + *, + remove_transcription_from_clipboard: bool = False, + ) -> None: + _ = remove_transcription_from_clipboard raise SystemExit("Wayland text injection is not supported yet.") def run_tray(self, _state_getter: Callable[[], str], _on_quit: Callable[[], None]) -> None: diff --git a/src/desktop_x11.py b/src/desktop_x11.py index de8a288..59dd189 100644 --- a/src/desktop_x11.py +++ b/src/desktop_x11.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging import threading +import time import warnings from typing import Callable, Iterable @@ -33,6 +34,7 @@ MOD_MAP = { "cmd": X.Mod4Mask, "command": X.Mod4Mask, } +CLIPBOARD_RESTORE_DELAY_SEC = 0.15 class X11Adapter: @@ -70,17 +72,35 @@ class X11Adapter: thread = threading.Thread(target=self._listen, args=("Escape", callback), daemon=True) thread.start() - def inject_text(self, text: str, backend: str) -> None: + def inject_text( + self, + text: str, + backend: str, + *, + remove_transcription_from_clipboard: bool = False, + ) -> None: backend = (backend or "").strip().lower() if backend in ("", "clipboard"): + previous_clipboard = None + if remove_transcription_from_clipboard: + previous_clipboard = self._read_clipboard_text() self._write_clipboard(text) self._paste_clipboard() + if remove_transcription_from_clipboard: + time.sleep(CLIPBOARD_RESTORE_DELAY_SEC) + self._restore_clipboard_text(previous_clipboard) return if backend == "injection": self._type_text(text) return raise ValueError(f"unknown injection backend: {backend}") + def _read_clipboard_text(self) -> str | None: + Gtk.init([]) + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + text = clipboard.wait_for_text() + return str(text) if text is not None else None + def run_tray(self, state_getter: Callable[[], str], on_quit: Callable[[], None]) -> None: self.menu = Gtk.Menu() quit_item = Gtk.MenuItem(label="Quit") @@ -165,6 +185,14 @@ class X11Adapter: while Gtk.events_pending(): Gtk.main_iteration() + def _restore_clipboard_text(self, text: str | None) -> None: + Gtk.init([]) + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + clipboard.set_text(text or "", -1) + clipboard.store() + while Gtk.events_pending(): + Gtk.main_iteration() + def _paste_clipboard(self) -> None: dpy = display.Display() self._send_combo(dpy, ["Control_L", "Shift_L", "v"]) diff --git a/src/leld.py b/src/leld.py index 73ff0f7..bdc2ec2 100755 --- a/src/leld.py +++ b/src/leld.py @@ -226,7 +226,13 @@ class Daemon: self.set_state(State.OUTPUTTING) logging.info("outputting started") backend = self.cfg.injection.backend - self.desktop.inject_text(text, backend) + self.desktop.inject_text( + text, + backend, + remove_transcription_from_clipboard=( + self.cfg.injection.remove_transcription_from_clipboard + ), + ) except Exception as exc: logging.error("output failed: %s", exc) finally: diff --git a/tests/test_config.py b/tests/test_config.py index cb12e0a..365e6c3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,6 +25,7 @@ class ConfigTests(unittest.TestCase): self.assertEqual(cfg.stt.model, "base") self.assertEqual(cfg.stt.device, "cpu") self.assertEqual(cfg.injection.backend, "clipboard") + self.assertFalse(cfg.injection.remove_transcription_from_clipboard) self.assertTrue(cfg.ai.enabled) self.assertEqual(cfg.vocabulary.replacements, []) self.assertEqual(cfg.vocabulary.terms, []) @@ -38,7 +39,10 @@ class ConfigTests(unittest.TestCase): "daemon": {"hotkey": "Ctrl+space"}, "recording": {"input": 3}, "stt": {"model": "small", "device": "cuda"}, - "injection": {"backend": "injection"}, + "injection": { + "backend": "injection", + "remove_transcription_from_clipboard": True, + }, "ai": {"enabled": False}, "vocabulary": { "replacements": [ @@ -62,6 +66,7 @@ class ConfigTests(unittest.TestCase): self.assertEqual(cfg.stt.model, "small") self.assertEqual(cfg.stt.device, "cuda") self.assertEqual(cfg.injection.backend, "injection") + self.assertTrue(cfg.injection.remove_transcription_from_clipboard) self.assertFalse(cfg.ai.enabled) self.assertEqual(cfg.vocabulary.max_rules, 100) self.assertEqual(cfg.vocabulary.max_terms, 200) @@ -92,6 +97,7 @@ class ConfigTests(unittest.TestCase): self.assertEqual(cfg.stt.model, "tiny") self.assertEqual(cfg.stt.device, "cpu") self.assertEqual(cfg.injection.backend, "clipboard") + self.assertFalse(cfg.injection.remove_transcription_from_clipboard) self.assertFalse(cfg.ai.enabled) self.assertEqual(cfg.vocabulary.replacements, []) @@ -104,6 +110,15 @@ class ConfigTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "injection.backend"): load(str(path)) + def test_invalid_clipboard_remove_option_raises(self): + payload = {"injection": {"remove_transcription_from_clipboard": "yes"}} + with tempfile.TemporaryDirectory() as td: + path = Path(td) / "config.json" + path.write_text(json.dumps(payload), encoding="utf-8") + + with self.assertRaisesRegex(ValueError, "injection.remove_transcription_from_clipboard"): + load(str(path)) + def test_removed_logging_section_raises(self): payload = {"logging": {"log_transcript": True}} with tempfile.TemporaryDirectory() as td: diff --git a/tests/test_leld.py b/tests/test_leld.py index 5e3d50d..d64cbea 100644 --- a/tests/test_leld.py +++ b/tests/test_leld.py @@ -19,8 +19,14 @@ class FakeDesktop: self.inject_calls = [] self.quit_calls = 0 - def inject_text(self, text: str, backend: str) -> None: - self.inject_calls.append((text, backend)) + def inject_text( + self, + text: str, + backend: str, + *, + remove_transcription_from_clipboard: bool = False, + ) -> None: + self.inject_calls.append((text, backend, remove_transcription_from_clipboard)) def request_quit(self) -> None: self.quit_calls += 1 @@ -101,7 +107,7 @@ class DaemonTests(unittest.TestCase): daemon.toggle() self.assertEqual(daemon.get_state(), leld.State.IDLE) - self.assertEqual(desktop.inject_calls, [("hello world", "clipboard")]) + self.assertEqual(desktop.inject_calls, [("hello world", "clipboard", False)]) @patch("leld.stop_audio_recording", return_value=FakeAudio(8)) @patch("leld.start_audio_recording", return_value=(object(), object())) @@ -143,7 +149,7 @@ class DaemonTests(unittest.TestCase): daemon.toggle() daemon.toggle() - self.assertEqual(desktop.inject_calls, [("good morning Marta", "clipboard")]) + self.assertEqual(desktop.inject_calls, [("good morning Marta", "clipboard", False)]) def test_transcribe_skips_hints_when_model_does_not_support_them(self): desktop = FakeDesktop() @@ -189,6 +195,28 @@ class DaemonTests(unittest.TestCase): daemon_verbose = leld.Daemon(cfg, desktop, verbose=True) self.assertTrue(daemon_verbose.log_transcript) + @patch("leld.stop_audio_recording", return_value=FakeAudio(8)) + @patch("leld.start_audio_recording", return_value=(object(), object())) + def test_passes_clipboard_remove_option_to_desktop(self, _start_mock, _stop_mock): + desktop = FakeDesktop() + model = FakeModel(text="hello world") + cfg = self._config() + cfg.injection.remove_transcription_from_clipboard = True + + with patch("leld._build_whisper_model", return_value=model): + daemon = leld.Daemon(cfg, desktop, verbose=False) + daemon.ai_processor = FakeAIProcessor() + daemon._start_stop_worker = ( + lambda stream, record, trigger, process_audio: daemon._stop_and_process( + stream, record, trigger, process_audio + ) + ) + + daemon.toggle() + daemon.toggle() + + self.assertEqual(desktop.inject_calls, [("hello world", "clipboard", True)]) + def test_state_changes_are_debug_level(self): desktop = FakeDesktop() with patch("leld._build_whisper_model", return_value=FakeModel()):