From b52ab0c70af12b80257b45ac91e5ea71b01549de Mon Sep 17 00:00:00 2001 From: Yuval Adam <_@yuv.al> Date: Fri, 24 Jul 2026 13:17:51 +0200 Subject: Show an IBus preedit spinner while transcribing --- README.md | 2 +- SPEC.md | 2 +- src/parley/daemon.py | 6 ++++++ src/parley/ibus_engine.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++ tests/test_ibus_engine.py | 42 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/test_ibus_engine.py diff --git a/README.md b/README.md index 4fb952e..a4ee32d 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Parley records your microphone, transcribes speech locally with Parakeet, and in ## Features - local recording and transcription -- semantic text insertion through IBus +- semantic text insertion and in-field processing feedback through IBus - clipboard fallback when insertion is unavailable - GNOME top-bar status and controls - global shortcut and command-line control diff --git a/SPEC.md b/SPEC.md index 276565b..d105cf5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -239,7 +239,7 @@ Regardless of selected backend, a successful transcript is saved and copied to t ### 7.1 Primary candidate: IBus `commit_text` -A minimal persistent passthrough IBus engine is implemented. When it owns the focused input context, it commits the transcript as semantic text. Initial end-to-end insertion and ordinary passthrough typing work in Firefox and GNOME Console; the full application and input-behavior matrix is still required. +A minimal persistent passthrough IBus engine is implemented. When it owns the focused input context, it commits the transcript as semantic text. While transcription is running, it uses temporary IBus preedit text for an animated in-field processing indicator; the indicator is never inserted into the document. Initial end-to-end insertion and ordinary passthrough typing work across the applications recorded in the bake-off matrix; the full input-behavior matrix is still required. Benefits: diff --git a/src/parley/daemon.py b/src/parley/daemon.py index 4330cc6..de76bcd 100644 --- a/src/parley/daemon.py +++ b/src/parley/daemon.py @@ -175,6 +175,12 @@ class Daemon: open_folder(directory) def _on_state(self, state: State) -> None: + config = self.controller.config + self.ibus.set_processing( + state is State.TRANSCRIBING + and config.auto_insert + and config.insertion_mode == "ibus" + ) self._emit("StateChanged", GLib.Variant("(s)", (state.value,))) self._properties_changed("State", GLib.Variant("s", state.value)) diff --git a/src/parley/ibus_engine.py b/src/parley/ibus_engine.py index a14a139..1f80868 100644 --- a/src/parley/ibus_engine.py +++ b/src/parley/ibus_engine.py @@ -17,15 +17,19 @@ class ParleyIBusEngine(IBus.Engine): """Pass ordinary keys through and accept explicit semantic text commits.""" focused: "ParleyIBusEngine | None" = None + integration: "IBusIntegration | None" = None def do_process_key_event(self, keyval: int, keycode: int, state: int) -> bool: return False def do_focus_in(self) -> None: ParleyIBusEngine.focused = self + if ParleyIBusEngine.integration is not None: + ParleyIBusEngine.integration.refresh_preedit() def do_focus_out(self) -> None: if ParleyIBusEngine.focused is self: + self.hide_preedit_text() ParleyIBusEngine.focused = None def commit(self, text: str) -> str: @@ -36,9 +40,15 @@ class ParleyIBusEngine(IBus.Engine): class IBusIntegration: """Register Parley's engine and reconnect after a rare IBus restart.""" + SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") + def __init__(self, *, activate_on_connect: bool = False) -> None: IBus.init() self.activate_on_connect = activate_on_connect + self._processing = False + self._spinner_frame = 0 + self._spinner_source = 0 + ParleyIBusEngine.integration = self self.bus: IBus.Bus | None = None self.factory: IBus.Factory | None = None self.component: IBus.Component | None = None @@ -53,6 +63,7 @@ class IBusIntegration: return self.bus is not None and self.bus.is_connected() def commit(self, text: str) -> str: + self.set_processing(False) if not self.available: raise IBusInsertionError("IBus is unavailable") engine = ParleyIBusEngine.focused @@ -60,8 +71,48 @@ class IBusIntegration: raise IBusInsertionError("Parley does not own the focused input context") return engine.commit(text) + def set_processing(self, processing: bool) -> None: + if processing == self._processing: + return + self._processing = processing + if processing: + self._spinner_frame = 0 + self.refresh_preedit() + if not self._spinner_source: + self._spinner_source = GLib.timeout_add(120, self._advance_spinner) + else: + if self._spinner_source: + GLib.Source.remove(self._spinner_source) + self._spinner_source = 0 + engine = ParleyIBusEngine.focused + if engine is not None: + engine.hide_preedit_text() + + def refresh_preedit(self) -> None: + engine = ParleyIBusEngine.focused + if not self._processing or engine is None: + return + message = f"{self.SPINNER_FRAMES[self._spinner_frame]} Transcribing…" + engine.update_preedit_text_with_mode( + IBus.Text.new_from_string(message), + len(message), + True, + IBus.PreeditFocusMode.CLEAR, + ) + + def _advance_spinner(self) -> bool: + if not self._processing: + self._spinner_source = 0 + return GLib.SOURCE_REMOVE + self._spinner_frame = (self._spinner_frame + 1) % len(self.SPINNER_FRAMES) + self.refresh_preedit() + return GLib.SOURCE_CONTINUE + def close(self) -> None: + self.set_processing(False) self._closed = True + if ParleyIBusEngine.integration is self: + ParleyIBusEngine.integration = None if self._reconnect_source: GLib.Source.remove(self._reconnect_source) self._reconnect_source = 0 @@ -141,6 +192,7 @@ class IBusIntegration: self._reconnect_source = GLib.timeout_add_seconds(2, self._connect) def _clear_connection(self) -> None: + self.set_processing(False) ParleyIBusEngine.focused = None bus, self.bus = self.bus, None if bus is not None and self._disconnect_signal: diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py new file mode 100644 index 0000000..18be819 --- /dev/null +++ b/tests/test_ibus_engine.py @@ -0,0 +1,42 @@ +import unittest +from unittest.mock import MagicMock, patch + +from parley.ibus_engine import IBusIntegration, ParleyIBusEngine + + +class IBusSpinnerTests(unittest.TestCase): + def setUp(self) -> None: + self.integration = object.__new__(IBusIntegration) + self.integration._processing = False + self.integration._spinner_frame = 0 + self.integration._spinner_source = 0 + self.engine = MagicMock() + ParleyIBusEngine.focused = self.engine + + def tearDown(self) -> None: + ParleyIBusEngine.focused = None + + @patch("parley.ibus_engine.GLib.timeout_add", return_value=42) + def test_processing_uses_temporary_preedit_text( + self, timeout_add: MagicMock + ) -> None: + self.integration.set_processing(True) + + arguments = self.engine.update_preedit_text_with_mode.call_args.args + self.assertEqual(arguments[0].get_text(), "⠋ Transcribing…") + self.assertTrue(arguments[2]) + timeout_add.assert_called_once_with(120, self.integration._advance_spinner) + + @patch("parley.ibus_engine.GLib.Source.remove") + def test_stopping_processing_hides_preedit(self, remove: MagicMock) -> None: + self.integration._processing = True + self.integration._spinner_source = 42 + + self.integration.set_processing(False) + + remove.assert_called_once_with(42) + self.engine.hide_preedit_text.assert_called_once_with() + + +if __name__ == "__main__": + unittest.main() -- cgit v1.3.1