diff --git a/src/vocalinux/text_injection/ibus_engine.py b/src/vocalinux/text_injection/ibus_engine.py index 4f20ba2..e7cfd49 100644 --- a/src/vocalinux/text_injection/ibus_engine.py +++ b/src/vocalinux/text_injection/ibus_engine.py @@ -24,7 +24,7 @@ import sys import threading import time from pathlib import Path -from typing import Optional +from typing import Callable, Optional logger = logging.getLogger(__name__) @@ -389,6 +389,23 @@ def restore_xkb_layout(layout: str, variant: str = "", option: str = "") -> bool return False +def _handle_engine_destroy( + active_instance: Optional["VocalinuxEngine"], + current_instance: object, + ibus_available: bool, + super_destroy: Optional[Callable[[], None]] = None, +) -> Optional["VocalinuxEngine"]: + """Compute next active engine state and invoke optional parent destroy.""" + next_active_instance = active_instance + if active_instance is current_instance: + next_active_instance = None + + if ibus_available and super_destroy is not None: + super_destroy() + + return next_active_instance + + def switch_engine(engine_name: str) -> bool: """Switch to the specified IBus engine.""" import time @@ -568,10 +585,15 @@ class VocalinuxEngine(IBus.Engine if IBUS_AVAILABLE else object): def do_destroy(self) -> None: """Called when the engine instance is destroyed by IBus (e.g. layout switch).""" logger.debug("VocalinuxEngine instance destroyed") - if VocalinuxEngine._active_instance is self: - VocalinuxEngine._active_instance = None + super_destroy: Optional[Callable[[], None]] = None if IBUS_AVAILABLE: - super().do_destroy() + super_destroy = super().do_destroy + VocalinuxEngine._active_instance = _handle_engine_destroy( + VocalinuxEngine._active_instance, + self, + IBUS_AVAILABLE, + super_destroy, + ) def do_focus_in(self) -> None: """Called when the engine gains focus.""" diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index f6b4bdc..4939612 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -1148,45 +1148,78 @@ class TestWaylandXkbLayoutSkipping(unittest.TestCase): class TestVocalinuxEngineDestroy(unittest.TestCase): """Tests for VocalinuxEngine.do_destroy (layout switch resilience).""" - @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", False) def test_do_destroy_clears_active_instance(self): - """Test do_destroy clears _active_instance when called on the active engine.""" - from vocalinux.text_injection.ibus_engine import VocalinuxEngine + """Test destroy handler clears active instance when called on active engine.""" + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy - engine = VocalinuxEngine.__new__(VocalinuxEngine) - VocalinuxEngine._active_instance = engine + engine = object() + next_active = _handle_engine_destroy( + active_instance=engine, + current_instance=engine, + ibus_available=False, + ) - engine.do_destroy() + self.assertIsNone(next_active) - self.assertIsNone(VocalinuxEngine._active_instance) - - @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", False) def test_do_destroy_ignores_different_instance(self): - """Test do_destroy doesn't clear _active_instance if called on a different engine.""" - from vocalinux.text_injection.ibus_engine import VocalinuxEngine + """Test destroy handler keeps active instance for different engine.""" + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy - active_engine = VocalinuxEngine.__new__(VocalinuxEngine) - old_engine = VocalinuxEngine.__new__(VocalinuxEngine) - VocalinuxEngine._active_instance = active_engine + active_engine = object() + old_engine = object() - old_engine.do_destroy() + next_active = _handle_engine_destroy( + active_instance=active_engine, + current_instance=old_engine, + ibus_available=False, + ) - self.assertIs(VocalinuxEngine._active_instance, active_engine) + self.assertIs(next_active, active_engine) def test_do_destroy_calls_super_when_ibus_available(self): - """Test do_destroy calls super().do_destroy() when IBus is available.""" - from vocalinux.text_injection.ibus_engine import VocalinuxEngine + """Test destroy handler calls parent destroy callback when IBus available.""" + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy - engine = VocalinuxEngine.__new__(VocalinuxEngine) - VocalinuxEngine._active_instance = None + active_engine = object() + mock_super_destroy = MagicMock() - mock_ibus.Engine.do_destroy = MagicMock() - try: - with patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True): - engine.do_destroy() - mock_ibus.Engine.do_destroy.assert_called_once() - finally: - del mock_ibus.Engine.do_destroy + _handle_engine_destroy( + active_instance=active_engine, + current_instance=object(), + ibus_available=True, + super_destroy=mock_super_destroy, + ) + + mock_super_destroy.assert_called_once() + + def test_do_destroy_skips_super_when_ibus_unavailable(self): + """Test destroy handler skips parent destroy callback when IBus unavailable.""" + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy + + mock_super_destroy = MagicMock() + + _handle_engine_destroy( + active_instance=object(), + current_instance=object(), + ibus_available=False, + super_destroy=mock_super_destroy, + ) + + mock_super_destroy.assert_not_called() + + def test_do_destroy_handles_missing_super_callback(self): + """Test destroy handler works when no parent destroy callback is provided.""" + from vocalinux.text_injection.ibus_engine import _handle_engine_destroy + + active_engine = object() + next_active = _handle_engine_destroy( + active_instance=active_engine, + current_instance=active_engine, + ibus_available=True, + super_destroy=None, + ) + + self.assertIsNone(next_active) if __name__ == "__main__":