summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorYuval Adam <_@yuv.al>2026-07-24 12:58:51 +0200
committerYuval Adam <_@yuv.al>2026-07-24 12:58:51 +0200
commit6e88c83227a94b331bbb3ac08152fee77611ddd3 (patch)
tree8ba4cab9dbab9da0b1f34f7d8403ef88dd873e64 /src
parent8732722aca10833d8799ada6f82ed334ce2e085d (diff)
Host the IBus engine inside parleyd
Diffstat (limited to 'src')
-rw-r--r--src/parley/daemon.py9
-rw-r--r--src/parley/desktop.py32
-rw-r--r--src/parley/ibus_engine.py168
3 files changed, 84 insertions, 125 deletions
diff --git a/src/parley/daemon.py b/src/parley/daemon.py
index 5a2cb53..63d4697 100644
--- a/src/parley/daemon.py
+++ b/src/parley/daemon.py
@@ -13,8 +13,9 @@ from gi.repository import Gio, GLib, GLibUnix
from .config import Config
from .controller import Controller
-from .desktop import copy_text, insert_text_ibus, notify, open_folder
+from .desktop import copy_text, notify, open_folder
from .errors import ParleyError
+from .ibus_engine import IBusIntegration
from .state import State
@@ -31,6 +32,7 @@ class Daemon:
self.node_info = Gio.DBusNodeInfo.new_for_xml(xml)
self.interface_info = self.node_info.interfaces[0]
self.loop = GLib.MainLoop()
+ self.ibus = IBusIntegration()
self.connection: Gio.DBusConnection | None = None
self.registration_id = 0
self.controller = Controller(
@@ -56,6 +58,7 @@ class Daemon:
self.loop.run()
finally:
self.controller.close()
+ self.ibus.close()
if self.connection is not None and self.registration_id:
self.connection.unregister_object(self.registration_id)
Gio.bus_unown_name(owner_id)
@@ -153,7 +156,7 @@ class Daemon:
backend = self.controller.config.insertion_mode
if backend == "ibus":
try:
- message = insert_text_ibus(text)
+ message = self.ibus.commit(text)
except ParleyError as error:
copy_text(text)
self._emit(
@@ -194,7 +197,7 @@ class Daemon:
config = self.controller.config
if config.auto_insert and config.insertion_mode == "ibus":
try:
- message = insert_text_ibus(text)
+ message = self.ibus.commit(text)
except ParleyError as error:
self._emit(
"InsertionFinished",
diff --git a/src/parley/desktop.py b/src/parley/desktop.py
index e4c5750..37f8a7f 100644
--- a/src/parley/desktop.py
+++ b/src/parley/desktop.py
@@ -7,10 +7,6 @@ import subprocess
from .errors import ParleyError
-IBUS_BUS_NAME = "org.parley.IBus1"
-IBUS_OBJECT_PATH = "/org/parley/IBus1"
-
-
class DesktopIntegrationError(ParleyError):
pass
@@ -37,34 +33,6 @@ 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
index 1de2a11..7f65a99 100644
--- a/src/parley/ibus_engine.py
+++ b/src/parley/ibus_engine.py
@@ -1,30 +1,16 @@
-"""Minimal persistent passthrough IBus engine for insertion experiments."""
-
-import signal
-import sys
+"""Persistent passthrough IBus engine hosted by the Parley daemon."""
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
+from gi.repository import GLib, IBus
+from .errors import ParleyError
-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 IBusInsertionError(ParleyError):
+ pass
class ParleyIBusEngine(IBus.Engine):
@@ -51,24 +37,61 @@ class ParleyIBusEngine(IBus.Engine):
) -> None:
self._purpose = purpose
- def commit(self, text: str) -> tuple[bool, str]:
+ def commit(self, text: str) -> str:
if self._purpose in (IBus.InputPurpose.PASSWORD, IBus.InputPurpose.PIN):
- return False, "refusing insertion into a password or PIN field"
+ raise IBusInsertionError(
+ "refusing insertion into a password or PIN field"
+ )
self.commit_text(IBus.Text.new_from_string(text))
- return True, "text committed through IBus"
+ return "text committed through IBus"
+
+class IBusIntegration:
+ """Register Parley's engine and reconnect after a rare IBus restart."""
-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.bus: IBus.Bus | None = None
+ self.factory: IBus.Factory | None = None
+ self.component: IBus.Component | None = None
+ self._disconnect_signal = 0
+ self._reconnect_source = 0
+ self._closed = False
+ self._connect()
- self.factory = IBus.Factory.new(self.ibus.get_connection())
- self.factory.add_engine("parley", ParleyIBusEngine.__gtype__)
- self.component = IBus.Component.new(
+ @property
+ def available(self) -> bool:
+ return self.bus is not None and self.bus.is_connected()
+
+ def commit(self, text: str) -> str:
+ if not self.available:
+ raise IBusInsertionError("IBus is unavailable")
+ engine = ParleyIBusEngine.focused
+ if engine is None:
+ raise IBusInsertionError(
+ "Parley does not own the focused input context"
+ )
+ return engine.commit(text)
+
+ def close(self) -> None:
+ self._closed = True
+ if self._reconnect_source:
+ GLib.source_remove(self._reconnect_source)
+ self._reconnect_source = 0
+ self._clear_connection()
+
+ def _connect(self) -> bool:
+ self._reconnect_source = 0
+ if self._closed:
+ return GLib.SOURCE_REMOVE
+ bus = IBus.Bus.new()
+ if not bus.is_connected():
+ self._schedule_reconnect()
+ return GLib.SOURCE_REMOVE
+
+ factory = IBus.Factory.new(bus.get_connection())
+ factory.add_engine("parley", ParleyIBusEngine.__gtype__)
+ component = IBus.Component.new(
"org.parley.IBus",
"Parley dictation passthrough engine",
"0.1.0",
@@ -78,7 +101,7 @@ class EngineApplication:
"",
"",
)
- self.component.add_engine(
+ component.add_engine(
IBus.EngineDesc.new(
"parley",
"Parley Dictation",
@@ -90,67 +113,32 @@ class EngineApplication:
"default",
)
)
- if not self.ibus.register_component(self.component):
- raise RuntimeError("could not register the Parley IBus component")
+ if not bus.register_component(component):
+ factory.destroy()
+ self._schedule_reconnect()
+ return GLib.SOURCE_REMOVE
- 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()
+ self.bus = bus
+ self.factory = factory
+ self.component = component
+ self._disconnect_signal = bus.connect("disconnected", self._on_disconnected)
return GLib.SOURCE_REMOVE
+ def _on_disconnected(self, bus: IBus.Bus) -> None:
+ self._clear_connection()
+ self._schedule_reconnect()
-def main() -> int:
- try:
- return EngineApplication().run()
- except (GLib.Error, RuntimeError) as error:
- print(f"parley-ibus: {error}", file=sys.stderr)
- return 1
-
+ def _schedule_reconnect(self) -> None:
+ if not self._closed and not self._reconnect_source:
+ self._reconnect_source = GLib.timeout_add_seconds(2, self._connect)
-if __name__ == "__main__":
- raise SystemExit(main())
+ def _clear_connection(self) -> None:
+ ParleyIBusEngine.focused = None
+ bus, self.bus = self.bus, None
+ if bus is not None and self._disconnect_signal:
+ bus.disconnect(self._disconnect_signal)
+ self._disconnect_signal = 0
+ factory, self.factory = self.factory, None
+ if factory is not None:
+ factory.destroy()
+ self.component = None