summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md2
-rw-r--r--SPEC.md2
-rw-r--r--src/parley/daemon.py6
-rw-r--r--src/parley/ibus_engine.py52
-rw-r--r--tests/test_ibus_engine.py42
5 files changed, 102 insertions, 2 deletions
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()