summaryrefslogtreecommitdiff
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
parenta0e54437fe03b36903faa8b600b6ba903cb15392 (diff)
Add IBus passthrough insertion prototype
-rw-r--r--README.md18
-rw-r--r--data/systemd/parley-ibus.service13
-rw-r--r--packaging/arch/PKGBUILD3
-rw-r--r--pyproject.toml1
-rwxr-xr-xscripts/disable-ibus-test19
-rwxr-xr-xscripts/enable-ibus-test28
-rwxr-xr-xscripts/install-dev4
-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
11 files changed, 328 insertions, 13 deletions
diff --git a/README.md b/README.md
index 1e091cb..a603464 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,8 @@ By default it finds `transcribe-cli` on `PATH` and the model at `$XDG_DATA_HOME/
- `PARLEY_TRANSCRIPT_DIR`
- `PARLEY_DEVICE`
- `PARLEY_LANGUAGE`
+- `PARLEY_AUTO_INSERT`
+- `PARLEY_INSERTION_MODE` (`clipboard` or `ibus`)
Run tests without desktop, microphone, or model dependencies:
@@ -103,6 +105,22 @@ gnome-extensions info parley@org.parley
The top-bar microphone shows daemon state and its menu provides start/stop, cancel, copy-last, and open-folder actions. Source changes are visible through the symlink, but GNOME Shell must reload the extension; toggle it off and on with the Extensions app. A newly installed extension may require one logout/login on Wayland before Shell discovers it.
+### IBus insertion experiment
+
+The development installer also starts a minimal persistent passthrough IBus engine. Enable the experiment with:
+
+```bash
+./scripts/enable-ibus-test
+```
+
+This saves the current engine, selects Parley, and enables automatic IBus insertion in the daemon. Put the caret in a normal text field and use the recording shortcut twice. Successful transcripts are still saved and copied before insertion. Password and PIN purposes are refused where the application reports them.
+
+This is a bake-off prototype: while it is enabled, explicitly test normal typing, modifiers, shortcuts, compose/dead keys, layout switching, terminals, browsers, and Electron applications. Restore the previous engine and clipboard-only mode with:
+
+```bash
+./scripts/disable-ibus-test
+```
+
## Arch Linux package
The local PKGBUILD installs `parleyd` and the other executables under `/usr/bin`, the systemd **user** unit under `/usr/lib/systemd/user`, and the session D-Bus service under `/usr/share/dbus-1/services`. It builds from the latest committed state of this checkout:
diff --git a/data/systemd/parley-ibus.service b/data/systemd/parley-ibus.service
new file mode 100644
index 0000000..c0a89ee
--- /dev/null
+++ b/data/systemd/parley-ibus.service
@@ -0,0 +1,13 @@
+[Unit]
+Description=Parley IBus insertion engine
+After=graphical-session.target
+PartOf=graphical-session.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/parley-ibus
+Restart=on-failure
+RestartSec=2
+
+[Install]
+WantedBy=default.target
diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD
index f527d65..7ade6df 100644
--- a/packaging/arch/PKGBUILD
+++ b/packaging/arch/PKGBUILD
@@ -8,6 +8,7 @@ license=('GPL-3.0-or-later')
depends=(
'ffmpeg'
'gnome-shell'
+ 'ibus'
'libnotify'
'python'
'python-gobject'
@@ -43,6 +44,8 @@ package() {
install -Dm644 data/systemd/parley.service \
"$pkgdir/usr/lib/systemd/user/parley.service"
+ install -Dm644 data/systemd/parley-ibus.service \
+ "$pkgdir/usr/lib/systemd/user/parley-ibus.service"
install -Dm644 data/dbus/org.parley.Transcription1.service \
"$pkgdir/usr/share/dbus-1/services/org.parley.Transcription1.service"
install -Dm644 src/parley/interfaces/org.parley.Transcription1.xml \
diff --git a/pyproject.toml b/pyproject.toml
index cf91954..ff0f11a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,7 @@ authors = [{ name = "Parley contributors" }]
[project.scripts]
parley = "parley.cli:main"
+parley-ibus = "parley.ibus_engine:main"
parleyd = "parley.daemon:main"
parleyctl = "parley.ctl:main"
diff --git a/scripts/disable-ibus-test b/scripts/disable-ibus-test
new file mode 100755
index 0000000..fe8014c
--- /dev/null
+++ b/scripts/disable-ibus-test
@@ -0,0 +1,19 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/parley
+dropin=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/parley.service.d/ibus-test.conf
+previous_file=$state_dir/previous-ibus-engine
+
+rm -f "$dropin"
+systemctl --user daemon-reload
+systemctl --user restart parley.service
+
+if [[ -s "$previous_file" ]]; then
+ previous=$(<"$previous_file")
+ ibus engine "$previous"
+ rm -f "$previous_file"
+ echo "Restored IBus engine: $previous"
+else
+ echo "Parley IBus automatic insertion disabled"
+fi
diff --git a/scripts/enable-ibus-test b/scripts/enable-ibus-test
new file mode 100755
index 0000000..b0a32cc
--- /dev/null
+++ b/scripts/enable-ibus-test
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+state_dir=${XDG_STATE_HOME:-$HOME/.local/state}/parley
+dropin_dir=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/parley.service.d
+mkdir -p "$state_dir" "$dropin_dir"
+
+previous=$(ibus engine)
+if [[ "$previous" != "parley" ]]; then
+ printf '%s\n' "$previous" > "$state_dir/previous-ibus-engine"
+fi
+
+systemctl --user start parley-ibus.service
+for _ in {1..20}; do
+ busctl --user --quiet list 2>/dev/null | grep -q '^org.parley.IBus1 ' && break
+ sleep 0.1
+done
+ibus engine parley >/dev/null 2>&1 || [[ $(ibus engine) == parley ]]
+
+cat > "$dropin_dir/ibus-test.conf" <<'EOF'
+[Service]
+Environment=PARLEY_AUTO_INSERT=1
+Environment=PARLEY_INSERTION_MODE=ibus
+EOF
+systemctl --user daemon-reload
+systemctl --user restart parley.service
+
+echo "Parley IBus test mode enabled (previous engine: $previous)"
diff --git a/scripts/install-dev b/scripts/install-dev
index 3602a0a..e0fbb99 100755
--- a/scripts/install-dev
+++ b/scripts/install-dev
@@ -12,11 +12,13 @@ uv pip install --editable .
mkdir -p "$unit_dir" "$dbus_dir"
sed "s|^ExecStart=.*|ExecStart=$root/.venv/bin/parleyd|" \
data/systemd/parley.service > "$unit_dir/parley.service"
+sed "s|^ExecStart=.*|ExecStart=$root/.venv/bin/parley-ibus|" \
+ data/systemd/parley-ibus.service > "$unit_dir/parley-ibus.service"
install -m644 data/dbus/org.parley.Transcription1.service \
"$dbus_dir/org.parley.Transcription1.service"
systemctl --user daemon-reload
-systemctl --user enable --now parley.service
+systemctl --user enable --now parley.service parley-ibus.service
echo "Installed local development service from $root"
echo "Run: uv run parleyctl status"
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())