diff options
| author | Yuval Adam <_@yuv.al> | 2026-07-24 16:13:38 +0200 |
|---|---|---|
| committer | Yuval Adam <_@yuv.al> | 2026-07-24 16:13:38 +0200 |
| commit | ced92a0eb1e0ca3e45ffd09133cd53a1ffa27b90 (patch) | |
| tree | 7cec00320036f04427c00e08a93473101a0e8dd3 | |
| parent | c04544d4e98cd5e4a611255fa677d4453a2f9756 (diff) | |
| -rw-r--r-- | SPEC.md | 7 | ||||
| -rw-r--r-- | extension/metadata.json | 2 | ||||
| -rw-r--r-- | packaging/arch/.SRCINFO | 2 | ||||
| -rw-r--r-- | packaging/arch/PKGBUILD | 2 | ||||
| -rw-r--r-- | parley/__init__.py | 2 | ||||
| -rw-r--r-- | parley/daemon.py | 16 | ||||
| -rw-r--r-- | parley/ibus_engine.py | 128 | ||||
| -rw-r--r-- | pyproject.toml | 2 | ||||
| -rw-r--r-- | tests/test_ibus_engine.py | 29 | ||||
| -rw-r--r-- | tests/test_release_metadata.py | 4 |
10 files changed, 159 insertions, 35 deletions
@@ -249,12 +249,7 @@ Benefits: - fewer focus races than clipboard plus a synthetic paste shortcut; - no remote-control permission prompt. -The critical assumption is that the engine must be active for the focused input context. Phase 3 must test both activation models: - -1. **Persistent passthrough engine:** Parley's engine remains selected and returns `False` for ordinary key events so normal typing and shortcuts continue to applications. -2. **Temporary activation:** save the current engine, switch to Parley, commit text, and restore the previous engine. - -The persistent mode is preferred only if it does not interfere with typing, shortcuts, compose behavior, keyboard layouts, or other input methods. Temporary activation is acceptable only if switching and focus are reliable. If neither is robust, IBus will not be the default. +The engine must be active for the focused input context. Parley therefore uses temporary activation for each dictation session: it remembers the current engine, selects Parley while recording and transcribing, commits semantic text, and restores the previous engine after insertion, cancellation, or failure. If GNOME changes input sources during the session, Parley records the latest choice, reacquires its engine, and restores that choice afterward. The committed transcript is Unicode text and does not depend on the active keyboard layout. ### 7.2 Safe fallback: clipboard only diff --git a/extension/metadata.json b/extension/metadata.json index 4c1faec..0e54da0 100644 --- a/extension/metadata.json +++ b/extension/metadata.json @@ -3,6 +3,6 @@ "name": "Parley Dictation", "description": "Control Parley and show local dictation status in the GNOME top bar", "shell-version": ["50"], - "version": 1, + "version": 2, "url": "https://github.com/yuvadm/parley" } diff --git a/packaging/arch/.SRCINFO b/packaging/arch/.SRCINFO index 6cde827..6a9b810 100644 --- a/packaging/arch/.SRCINFO +++ b/packaging/arch/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = parley pkgdesc = Private dictation for modern Linux - pkgver = 1.0.0 + pkgver = 1.1.0 pkgrel = 1 url = https://github.com/yuvadm/parley arch = any diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 155cdc9..2d8f8dd 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Yuval Adam <hello@yuv.al> pkgname=parley -pkgver=1.0.0 +pkgver=1.1.0 pkgrel=1 pkgdesc='Private dictation for modern Linux' arch=('any') diff --git a/parley/__init__.py b/parley/__init__.py index 7a66ef1..06878b3 100644 --- a/parley/__init__.py +++ b/parley/__init__.py @@ -1,3 +1,3 @@ """Terminal-independent recording and transcription core for Parley.""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/parley/daemon.py b/parley/daemon.py index ac76b84..80000b9 100644 --- a/parley/daemon.py +++ b/parley/daemon.py @@ -37,10 +37,7 @@ class Daemon: self.interface_info = self.node_info.interfaces[0] resolved_config = config or Config.load() self.loop = GLib.MainLoop() - self.ibus = IBusIntegration( - activate_on_connect=resolved_config.auto_insert - and resolved_config.insertion_mode == "ibus" - ) + self.ibus = IBusIntegration() self.connection: Gio.DBusConnection | None = None self.registration_id = 0 self.controller = Controller( @@ -178,11 +175,12 @@ class Daemon: 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" - ) + uses_ibus = config.auto_insert and config.insertion_mode == "ibus" + if uses_ibus and state is State.RECORDING: + self.ibus.begin_session() + self.ibus.set_processing(uses_ibus and state is State.TRANSCRIBING) + if state in {State.IDLE, State.ERROR}: + self.ibus.end_session() self._emit("StateChanged", GLib.Variant("(s)", (state.value,))) self._properties_changed("State", GLib.Variant("s", state.value)) diff --git a/parley/ibus_engine.py b/parley/ibus_engine.py index 77f1d9e..289cc22 100644 --- a/parley/ibus_engine.py +++ b/parley/ibus_engine.py @@ -41,13 +41,13 @@ class ParleyIBusEngine(IBus.Engine): class IBusIntegration: - """Register Parley's engine and reconnect after a rare IBus restart.""" + """Register Parley's engine and own IBus only during a dictation session.""" + ENGINE_NAME = "parley" SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏") - def __init__(self, *, activate_on_connect: bool = False) -> None: + def __init__(self) -> None: IBus.init() - self.activate_on_connect = activate_on_connect self._processing = False self._spinner_frame = 0 self._spinner_source = 0 @@ -56,8 +56,13 @@ class IBusIntegration: self.factory: IBus.Factory | None = None self.component: IBus.Component | None = None self._disconnect_signal = 0 + self._engine_changed_signal = 0 self._reconnect_source = 0 self._activation_source = 0 + self._activation_in_flight = False + self._restore_in_flight = False + self._session_active = False + self._restore_engine: str | None = None self._closed = False self._connect() @@ -65,6 +70,28 @@ class IBusIntegration: def available(self) -> bool: return self.bus is not None and self.bus.is_connected() + def begin_session(self) -> None: + """Temporarily select Parley and keep it selected during dictation.""" + if self._session_active: + return + self._session_active = True + current = self._current_engine_name() + if current and current != self.ENGINE_NAME: + self._restore_engine = current + self._schedule_activation(0) + + def end_session(self) -> None: + """Stop enforcing Parley and restore the latest non-Parley engine.""" + if not self._session_active: + return + self._session_active = False + self.set_processing(False) + if self._activation_source: + GLib.Source.remove(self._activation_source) + self._activation_source = 0 + + self._restore_input_engine() + def commit(self, text: str) -> str: self.set_processing(False) if not self.available: @@ -113,6 +140,12 @@ class IBusIntegration: def close(self) -> None: self.set_processing(False) + self._session_active = False + if self._restore_engine and self.available: + # This synchronous final request is deliberate: the GLib loop is no + # longer running, and Parley must not remain selected after shutdown. + self.bus.set_global_engine(self._restore_engine) + self._restore_engine = None self._closed = True if ParleyIBusEngine.integration is self: ParleyIBusEngine.integration = None @@ -134,7 +167,7 @@ class IBusIntegration: return GLib.SOURCE_REMOVE factory = IBus.Factory.new(bus.get_connection()) - factory.add_engine("parley", ParleyIBusEngine.__gtype__) + factory.add_engine(self.ENGINE_NAME, ParleyIBusEngine.__gtype__) component = IBus.Component.new( "org.parley.IBus", "Parley dictation passthrough engine", @@ -147,7 +180,7 @@ class IBusIntegration: ) component.add_engine( IBus.EngineDesc.new( - "parley", + self.ENGINE_NAME, "Parley Dictation", "Commit local dictation as text", "en", @@ -165,26 +198,89 @@ class IBusIntegration: self.bus = bus self.factory = factory self.component = component + bus.set_watch_ibus_signal(True) self._disconnect_signal = bus.connect("disconnected", self._on_disconnected) - if self.activate_on_connect: - self._activation_source = GLib.timeout_add(100, self._activate) + self._engine_changed_signal = bus.connect( + "global-engine-changed", self._on_global_engine_changed + ) + if self._session_active: + self._schedule_activation(0) return GLib.SOURCE_REMOVE + def _current_engine_name(self) -> str | None: + if not self.available: + return None + engine = self.bus.get_global_engine() + return engine.get_name() if engine is not None else None + + def _schedule_activation(self, delay_ms: int = 50) -> None: + if ( + self._closed + or not self._session_active + or self._activation_source + or self._activation_in_flight + ): + return + if delay_ms: + self._activation_source = GLib.timeout_add(delay_ms, self._activate) + else: + self._activation_source = GLib.idle_add(self._activate) + def _activate(self) -> bool: self._activation_source = 0 - if self.available: + if self.available and self._session_active: + self._activation_in_flight = True self.bus.set_global_engine_async( - "parley", 2_000, None, self._activation_finished + self.ENGINE_NAME, 2_000, None, self._activation_finished ) return GLib.SOURCE_REMOVE def _activation_finished(self, bus: IBus.Bus, result) -> None: + self._activation_in_flight = False + try: + bus.set_global_engine_async_finish(result) + except GLib.Error: + self._schedule_activation(250) + return + if self._session_active: + if self._current_engine_name() != self.ENGINE_NAME: + self._schedule_activation() + else: + self._restore_input_engine() + + def _restore_input_engine(self) -> None: + if ( + self._session_active + or self._activation_in_flight + or self._restore_in_flight + or not self._restore_engine + or not self.available + ): + return + if self._current_engine_name() != self.ENGINE_NAME: + self._restore_engine = None + return + self._restore_in_flight = True + self.bus.set_global_engine_async( + self._restore_engine, 2_000, None, self._restore_finished + ) + + def _restore_finished(self, bus: IBus.Bus, result) -> None: + self._restore_in_flight = False try: bus.set_global_engine_async_finish(result) except GLib.Error: - # Insertion remains available as a clipboard fallback. A later - # explicit selection can activate the engine. pass + finally: + self._restore_engine = None + + def _on_global_engine_changed(self, bus: IBus.Bus, engine_name: str) -> None: + if not self._session_active or engine_name == self.ENGINE_NAME: + return + # GNOME remains free to change input sources. Remember its latest choice, + # then reacquire Parley for semantic insertion and restore that choice later. + self._restore_engine = engine_name + self._schedule_activation() def _on_disconnected(self, bus: IBus.Bus) -> None: self._clear_connection() @@ -198,9 +294,15 @@ class IBusIntegration: self.set_processing(False) ParleyIBusEngine.focused = None bus, self.bus = self.bus, None - if bus is not None and self._disconnect_signal: - bus.disconnect(self._disconnect_signal) + if bus is not None: + if self._disconnect_signal: + bus.disconnect(self._disconnect_signal) + if self._engine_changed_signal: + bus.disconnect(self._engine_changed_signal) self._disconnect_signal = 0 + self._engine_changed_signal = 0 + self._activation_in_flight = False + self._restore_in_flight = False factory, self.factory = self.factory, None if factory is not None: factory.destroy() diff --git a/pyproject.toml b/pyproject.toml index d9219f7..f91f823 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "parley" -version = "1.0.0" +version = "1.1.0" description = "Private dictation for modern Linux" readme = "README.md" requires-python = ">=3.14" diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index 18be819..e3bb825 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -4,6 +4,35 @@ from unittest.mock import MagicMock, patch from parley.ibus_engine import IBusIntegration, ParleyIBusEngine +class IBusSessionTests(unittest.TestCase): + def setUp(self) -> None: + self.integration = object.__new__(IBusIntegration) + self.integration._session_active = False + self.integration._restore_engine = None + + def test_session_remembers_and_replaces_the_current_engine(self) -> None: + self.integration._current_engine_name = MagicMock( + return_value="xkb:il::heb" + ) + self.integration._schedule_activation = MagicMock() + + self.integration.begin_session() + + self.assertTrue(self.integration._session_active) + self.assertEqual(self.integration._restore_engine, "xkb:il::heb") + self.integration._schedule_activation.assert_called_once_with(0) + + def test_gnome_engine_change_is_reacquired_and_restored_later(self) -> None: + self.integration._session_active = True + self.integration._restore_engine = "xkb:us::eng" + self.integration._schedule_activation = MagicMock() + + self.integration._on_global_engine_changed(MagicMock(), "xkb:il::heb") + + self.assertEqual(self.integration._restore_engine, "xkb:il::heb") + self.integration._schedule_activation.assert_called_once_with() + + class IBusSpinnerTests(unittest.TestCase): def setUp(self) -> None: self.integration = object.__new__(IBusIntegration) diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index 164f7c4..24c1e33 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -20,7 +20,7 @@ class ReleaseMetadataTests(unittest.TestCase): package_version = re.search(r"^pkgver=(.+)$", pkgbuild, re.MULTILINE) self.assertIsNotNone(package_version) - self.assertEqual(__version__, "1.0.0") + self.assertEqual(__version__, "1.1.0") self.assertEqual(project_version, __version__) self.assertEqual(package_version.group(1), __version__) @@ -28,7 +28,7 @@ class ReleaseMetadataTests(unittest.TestCase): metadata = json.loads( (ROOT / "extension/metadata.json").read_text(encoding="utf-8") ) - self.assertEqual(metadata["version"], 1) + self.assertEqual(metadata["version"], 2) self.assertEqual(metadata["url"], "https://github.com/yuvadm/parley") |
