diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 12:46:45 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 12:46:45 +0200 |
| commit | c6a83f2392293bd34c0c9ba6286bf34063446027 (patch) | |
| tree | 8d4845328fa99a505bd83fe1d62be0ea6278861e /src | |
| parent | a0e54437fe03b36903faa8b600b6ba903cb15392 (diff) | |
Add IBus passthrough insertion prototype
Diffstat (limited to 'src')
| -rw-r--r-- | src/parley/config.py | 9 | ||||
| -rw-r--r-- | src/parley/daemon.py | 58 | ||||
| -rw-r--r-- | src/parley/desktop.py | 32 | ||||
| -rw-r--r-- | src/parley/ibus_engine.py | 156 |
4 files changed, 243 insertions, 12 deletions
diff --git a/src/parley/config.py b/src/parley/config.py index 9dd953e..89cf59c 100644 --- a/src/parley/config.py +++ b/src/parley/config.py @@ -31,6 +31,8 @@ class Config: device: str = "default" language: str = "en" sample_rate: int = 16_000 + auto_insert: bool = False + insertion_mode: str = "clipboard" @classmethod def from_environment(cls) -> "Config": @@ -60,6 +62,9 @@ class Config: ).expanduser(), device=os.environ.get("PARLEY_DEVICE", "default"), language=os.environ.get("PARLEY_LANGUAGE", "en"), + auto_insert=os.environ.get("PARLEY_AUTO_INSERT", "0").lower() + in {"1", "true", "yes", "on"}, + insertion_mode=os.environ.get("PARLEY_INSERTION_MODE", "clipboard"), ) def validate(self) -> None: @@ -75,3 +80,7 @@ class Config: ) if not self.model.is_file(): raise ConfigurationError(f"model not found: {self.model}") + if self.insertion_mode not in {"clipboard", "ibus"}: + raise ConfigurationError( + f"unsupported insertion mode: {self.insertion_mode}" + ) diff --git a/src/parley/daemon.py b/src/parley/daemon.py index 09fb566..5a2cb53 100644 --- a/src/parley/daemon.py +++ b/src/parley/daemon.py @@ -13,7 +13,7 @@ from gi.repository import Gio, GLib, GLibUnix from .config import Config from .controller import Controller -from .desktop import copy_text, notify, open_folder +from .desktop import copy_text, insert_text_ibus, notify, open_folder from .errors import ParleyError from .state import State @@ -132,8 +132,12 @@ class Daemon: str(self.controller.last_transcript_path or ""), ), "LastError": GLib.Variant("s", self.controller.last_error), - "InsertionMode": GLib.Variant("s", "clipboard"), - "AutoInsert": GLib.Variant("b", False), + "InsertionMode": GLib.Variant( + "s", self.controller.config.insertion_mode + ), + "AutoInsert": GLib.Variant( + "b", self.controller.config.auto_insert + ), } return values[property_name] @@ -143,14 +147,25 @@ class Daemon: copy_text(self.controller.last_transcript) def _insert_last(self) -> None: - try: - self._copy_last() - except ParleyError as error: - self._emit("InsertionFinished", GLib.Variant("(sbs)", ("clipboard", False, str(error)))) - raise - message = "copied; press Ctrl+V to insert" + if not self.controller.last_transcript_path: + raise ParleyError("no transcript is available") + text = self.controller.last_transcript + backend = self.controller.config.insertion_mode + if backend == "ibus": + try: + message = insert_text_ibus(text) + except ParleyError as error: + copy_text(text) + self._emit( + "InsertionFinished", + GLib.Variant("(sbs)", ("ibus", False, f"{error}; copied instead")), + ) + return + else: + copy_text(text) + message = "copied; press Ctrl+V to insert" self._emit( - "InsertionFinished", GLib.Variant("(sbs)", ("clipboard", True, message)) + "InsertionFinished", GLib.Variant("(sbs)", (backend, True, message)) ) def _open_transcript_folder(self) -> None: @@ -168,13 +183,32 @@ class Daemon: "LastTranscript", GLib.Variant("s", text), "LastTranscriptPath", GLib.Variant("s", str(path)), ) + clipboard_error = None try: copy_text(text) except ParleyError as error: + clipboard_error = error self.controller.last_error = str(error) self._on_error("clipboard-failed", str(error)) - notify("Transcription saved", f"Clipboard failed: {error}") - else: + + config = self.controller.config + if config.auto_insert and config.insertion_mode == "ibus": + try: + message = insert_text_ibus(text) + except ParleyError as error: + self._emit( + "InsertionFinished", + GLib.Variant("(sbs)", ("ibus", False, str(error))), + ) + fallback = "Copied; press Ctrl+V" if clipboard_error is None else "Transcript saved" + notify("Automatic insertion unavailable", fallback) + else: + self._emit( + "InsertionFinished", + GLib.Variant("(sbs)", ("ibus", True, message)), + ) + notify("Transcription ready", "Inserted through IBus") + elif clipboard_error is None: notify("Transcription ready", "Copied to the clipboard") def _on_error(self, code: str, message: str) -> None: diff --git a/src/parley/desktop.py b/src/parley/desktop.py index 37f8a7f..e4c5750 100644 --- a/src/parley/desktop.py +++ b/src/parley/desktop.py @@ -7,6 +7,10 @@ import subprocess from .errors import ParleyError +IBUS_BUS_NAME = "org.parley.IBus1" +IBUS_OBJECT_PATH = "/org/parley/IBus1" + + class DesktopIntegrationError(ParleyError): pass @@ -33,6 +37,34 @@ def copy_text(text: str) -> None: raise DesktopIntegrationError("xclip could not access the clipboard") +def insert_text_ibus(text: str) -> str: + """Ask the focused Parley IBus engine to commit semantic text.""" + try: + import gi + + gi.require_version("Gio", "2.0") + from gi.repository import Gio, GLib + + connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) + result = connection.call_sync( + IBUS_BUS_NAME, + IBUS_OBJECT_PATH, + IBUS_BUS_NAME, + "CommitText", + GLib.Variant("(s)", (text,)), + GLib.VariantType.new("(bs)"), + Gio.DBusCallFlags.NO_AUTO_START, + 2_000, + None, + ) + except Exception as error: + raise DesktopIntegrationError(f"IBus insertion is unavailable: {error}") from error + success, message = result.unpack() + if not success: + raise DesktopIntegrationError(message) + return message + + def notify(summary: str, body: str) -> None: executable = shutil.which("notify-send") if executable is not None: diff --git a/src/parley/ibus_engine.py b/src/parley/ibus_engine.py new file mode 100644 index 0000000..1de2a11 --- /dev/null +++ b/src/parley/ibus_engine.py @@ -0,0 +1,156 @@ +"""Minimal persistent passthrough IBus engine for insertion experiments.""" + +import signal +import sys + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("GLib", "2.0") +gi.require_version("GLibUnix", "2.0") +gi.require_version("IBus", "1.0") +from gi.repository import Gio, GLib, GLibUnix, GObject, IBus + + +BUS_NAME = "org.parley.IBus1" +OBJECT_PATH = "/org/parley/IBus1" +INTERFACE = BUS_NAME + +INTROSPECTION = """<node> + <interface name="org.parley.IBus1"> + <method name="CommitText"> + <arg name="text" type="s" direction="in"/> + <arg name="success" type="b" direction="out"/> + <arg name="message" type="s" direction="out"/> + </method> + </interface> +</node>""" + + +class ParleyIBusEngine(IBus.Engine): + """Pass ordinary keys through and accept explicit semantic text commits.""" + + focused: "ParleyIBusEngine | None" = None + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._purpose = IBus.InputPurpose.FREE_FORM + + def do_process_key_event(self, keyval: int, keycode: int, state: int) -> bool: + return False + + def do_focus_in(self) -> None: + ParleyIBusEngine.focused = self + + def do_focus_out(self) -> None: + if ParleyIBusEngine.focused is self: + ParleyIBusEngine.focused = None + + def do_set_content_type( + self, purpose: IBus.InputPurpose, hints: IBus.InputHints + ) -> None: + self._purpose = purpose + + def commit(self, text: str) -> tuple[bool, str]: + if self._purpose in (IBus.InputPurpose.PASSWORD, IBus.InputPurpose.PIN): + return False, "refusing insertion into a password or PIN field" + self.commit_text(IBus.Text.new_from_string(text)) + return True, "text committed through IBus" + + +class EngineApplication: + def __init__(self) -> None: + IBus.init() + self.loop = GLib.MainLoop() + self.ibus = IBus.Bus.new() + if not self.ibus.is_connected(): + raise RuntimeError("could not connect to IBus") + + self.factory = IBus.Factory.new(self.ibus.get_connection()) + self.factory.add_engine("parley", ParleyIBusEngine.__gtype__) + self.component = IBus.Component.new( + "org.parley.IBus", + "Parley dictation passthrough engine", + "0.1.0", + "GPL-3.0-or-later", + "Parley contributors", + "", + "", + "", + ) + self.component.add_engine( + IBus.EngineDesc.new( + "parley", + "Parley Dictation", + "Commit local dictation as text", + "en", + "GPL-3.0-or-later", + "Parley contributors", + "audio-input-microphone-symbolic", + "default", + ) + ) + if not self.ibus.register_component(self.component): + raise RuntimeError("could not register the Parley IBus component") + + self.connection = Gio.bus_get_sync(Gio.BusType.SESSION, None) + node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION) + self.registration_id = self.connection.register_object( + OBJECT_PATH, node.interfaces[0], self._method_call, None, None + ) + self.owner_id = Gio.bus_own_name_on_connection( + self.connection, BUS_NAME, Gio.BusNameOwnerFlags.NONE, None, None + ) + self.ibus.connect("disconnected", lambda _bus: self.loop.quit()) + GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGTERM, self._quit) + GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, self._quit) + + def run(self) -> int: + print("Parley IBus engine registered", file=sys.stderr) + try: + self.loop.run() + finally: + Gio.bus_unown_name(self.owner_id) + self.connection.unregister_object(self.registration_id) + self.factory.destroy() + return 0 + + def _method_call( + self, + connection: Gio.DBusConnection, + sender: str, + object_path: str, + interface_name: str, + method_name: str, + parameters: GLib.Variant, + invocation: Gio.DBusMethodInvocation, + ) -> None: + if method_name != "CommitText": + invocation.return_dbus_error( + f"{INTERFACE}.Error.UnknownMethod", f"unknown method: {method_name}" + ) + return + engine = ParleyIBusEngine.focused + if engine is None: + invocation.return_value( + GLib.Variant("(bs)", (False, "Parley does not own the focused input context")) + ) + return + success, message = engine.commit(parameters.unpack()[0]) + invocation.return_value(GLib.Variant("(bs)", (success, message))) + + def _quit(self) -> bool: + self.loop.quit() + return GLib.SOURCE_REMOVE + + +def main() -> int: + try: + return EngineApplication().run() + except (GLib.Error, RuntimeError) as error: + print(f"parley-ibus: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) |
