summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2026-07-24 12:46:45 +0200
committerYuval Adam <_@yuv.al>2026-07-24 12:46:45 +0200
commitc6a83f2392293bd34c0c9ba6286bf34063446027 (patch)
tree8d4845328fa99a505bd83fe1d62be0ea6278861e /src
parenta0e54437fe03b36903faa8b600b6ba903cb15392 (diff)
Add IBus passthrough insertion prototype
Diffstat (limited to 'src')
-rw-r--r--src/parley/config.py9
-rw-r--r--src/parley/daemon.py58
-rw-r--r--src/parley/desktop.py32
-rw-r--r--src/parley/ibus_engine.py156
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())