diff --git a/src/vocalinux/text_injection/text_injector.py b/src/vocalinux/text_injection/text_injector.py index abf21b7..bc12033 100644 --- a/src/vocalinux/text_injection/text_injector.py +++ b/src/vocalinux/text_injection/text_injector.py @@ -25,6 +25,7 @@ class DesktopEnvironment(Enum): """Enum representing the desktop environment.""" X11 = "x11" + X11_IBUS = "x11-ibus" # X11 with IBus engine (preferred for non-US layouts) WAYLAND = "wayland" WAYLAND_XDOTOOL = "wayland-xdotool" # Wayland with XWayland fallback WAYLAND_IBUS = "wayland-ibus" # Wayland with IBus engine (preferred) @@ -130,24 +131,28 @@ class TextInjector: def _check_dependencies(self): """Check for the required tools for text injection.""" + # Prefer IBus on both X11 and Wayland - it sends Unicode directly, + # bypassing keyboard layout issues entirely + if is_ibus_available(): + try: + self._ibus_injector = IBusTextInjector(auto_activate=True) + if self.environment == DesktopEnvironment.X11: + self.environment = DesktopEnvironment.X11_IBUS + else: + self.environment = DesktopEnvironment.WAYLAND_IBUS + logger.info( + f"Using IBus for {self.environment.value} text injection (best compatibility)" + ) + return + except Exception as e: + logger.warning(f"IBus initialization failed: {e}, trying alternatives") + if self.environment == DesktopEnvironment.X11: # Check for xdotool if not shutil.which("xdotool"): logger.error("xdotool not found. Please install it with: sudo apt install xdotool") raise RuntimeError("Missing required dependency: xdotool") else: - # For Wayland, prefer IBus as it works universally with any keyboard layout - # IBus is usually pre-installed on GNOME systems - if is_ibus_available(): - try: - self._ibus_injector = IBusTextInjector(auto_activate=True) - self.environment = DesktopEnvironment.WAYLAND_IBUS - self.wayland_tool = "ibus" - logger.info("Using IBus for Wayland text injection (best compatibility)") - return - except Exception as e: - logger.warning(f"IBus initialization failed: {e}, trying alternatives") - # Fallback: Check for wtype or ydotool for Wayland wtype_available = shutil.which("wtype") is not None ydotool_available = shutil.which("ydotool") is not None @@ -157,7 +162,10 @@ class TextInjector: # Verify ydotoold daemon is running before selecting ydotool try: subprocess.run( - ["ydotool", "type", ""], check=True, stderr=subprocess.PIPE, timeout=2 + ["ydotool", "type", ""], + check=True, + stderr=subprocess.PIPE, + timeout=2, ) self.wayland_tool = "ydotool" logger.info(f"Using {self.wayland_tool} for Wayland text injection") @@ -247,8 +255,10 @@ class TextInjector: logger.debug(f"Text to inject: '{text}'") try: - if self.environment == DesktopEnvironment.WAYLAND_IBUS: - # Use IBus for text injection + if ( + self.environment == DesktopEnvironment.WAYLAND_IBUS + or self.environment == DesktopEnvironment.X11_IBUS + ): if self._ibus_injector is not None: return self._ibus_injector.inject_text(text) else: @@ -360,7 +370,12 @@ class TextInjector: logger.debug(f"Injecting chunk {chunk_num}/{total_chunks}: '{chunk}'") subprocess.run( - cmd, env=env, check=True, stderr=subprocess.PIPE, text=True, timeout=5 + cmd, + env=env, + check=True, + stderr=subprocess.PIPE, + text=True, + timeout=5, ) # Add a larger delay between chunks @@ -374,7 +389,7 @@ class TextInjector: except subprocess.CalledProcessError as chunk_error: if retry < max_retries: logger.warning( - f"Retrying text injection (attempt {retry+1}/{max_retries}): " + f"Retrying text injection (attempt {retry + 1}/{max_retries}): " f"{chunk_error.stderr}" ) time.sleep(0.5) # Wait before retry @@ -384,7 +399,7 @@ class TextInjector: except subprocess.TimeoutExpired: if retry < max_retries: logger.warning( - f"Text injection timeout, retrying (attempt {retry+1}/{max_retries})" + f"Text injection timeout, retrying (attempt {retry + 1}/{max_retries})" ) time.sleep(0.5) else: diff --git a/tests/test_ibus_engine.py b/tests/test_ibus_engine.py index bd45980..fc0d30c 100644 --- a/tests/test_ibus_engine.py +++ b/tests/test_ibus_engine.py @@ -582,6 +582,52 @@ class TestTextInjectorWithIBus(unittest.TestCase): # Should be using IBus self.assertEqual(injector.environment, DesktopEnvironment.WAYLAND_IBUS) + @patch("vocalinux.text_injection.ibus_engine.IBUS_AVAILABLE", True) + @patch("vocalinux.text_injection.ibus_engine.is_ibus_available") + @patch("vocalinux.text_injection.ibus_engine.IBusTextInjector") + def test_x11_prefers_ibus(self, mock_injector_class, mock_ibus_available): + """Test that X11 environment prefers IBus when available.""" + mock_ibus_available.return_value = True + mock_injector_instance = MagicMock() + mock_injector_class.return_value = mock_injector_instance + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}): + from vocalinux.text_injection.text_injector import ( + DesktopEnvironment, + TextInjector, + ) + + with patch( + "vocalinux.text_injection.text_injector.is_ibus_available", + return_value=True, + ): + with patch( + "vocalinux.text_injection.text_injector.IBusTextInjector", + mock_injector_class, + ): + injector = TextInjector() + + self.assertEqual(injector.environment, DesktopEnvironment.X11_IBUS) + + @patch("vocalinux.text_injection.text_injector.is_ibus_available") + def test_x11_fallback_when_ibus_unavailable(self, mock_ibus_available): + """Test X11 falls back to xdotool when IBus unavailable.""" + mock_ibus_available.return_value = False + + with patch.dict("os.environ", {"XDG_SESSION_TYPE": "x11"}): + self.mock_which.side_effect = lambda cmd: ( + "/usr/bin/xdotool" if cmd == "xdotool" else None + ) + + from vocalinux.text_injection.text_injector import ( + DesktopEnvironment, + TextInjector, + ) + + injector = TextInjector() + + self.assertEqual(injector.environment, DesktopEnvironment.X11) + @patch("vocalinux.text_injection.text_injector.is_ibus_available") def test_wayland_fallback_when_ibus_unavailable(self, mock_ibus_available): """Test Wayland falls back to other tools when IBus unavailable.""" @@ -640,7 +686,7 @@ class TestTextInjectorWithIBus(unittest.TestCase): class TestDesktopEnvironmentEnumWithIBus(unittest.TestCase): - """Tests for DesktopEnvironment enum including IBus variant.""" + """Tests for DesktopEnvironment enum including IBus variants.""" def test_enum_includes_wayland_ibus(self): """Test that WAYLAND_IBUS enum value exists.""" @@ -648,6 +694,12 @@ class TestDesktopEnvironmentEnumWithIBus(unittest.TestCase): self.assertEqual(DesktopEnvironment.WAYLAND_IBUS.value, "wayland-ibus") + def test_enum_includes_x11_ibus(self): + """Test that X11_IBUS enum value exists.""" + from vocalinux.text_injection.text_injector import DesktopEnvironment + + self.assertEqual(DesktopEnvironment.X11_IBUS.value, "x11-ibus") + if __name__ == "__main__": unittest.main()