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 | |
| parent | a0e54437fe03b36903faa8b600b6ba903cb15392 (diff) | |
Add IBus passthrough insertion prototype
| -rw-r--r-- | README.md | 18 | ||||
| -rw-r--r-- | data/systemd/parley-ibus.service | 13 | ||||
| -rw-r--r-- | packaging/arch/PKGBUILD | 3 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rwxr-xr-x | scripts/disable-ibus-test | 19 | ||||
| -rwxr-xr-x | scripts/enable-ibus-test | 28 | ||||
| -rwxr-xr-x | scripts/install-dev | 4 | ||||
| -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 |
11 files changed, 328 insertions, 13 deletions
@@ -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()) |
