diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py index 67b7686..7daa2f9 100644 --- a/src/vocalinux/ui/dictation_overlay.py +++ b/src/vocalinux/ui/dictation_overlay.py @@ -134,6 +134,7 @@ class DictationOverlay: try: self._init_gtk_window() self._gtk_ready = True + self._GLib.idle_add(self.prewarm) except Exception as e: # Headless / missing display: keep controller usable for tests. logger.warning("Dictation overlay window unavailable: %s", e) @@ -277,10 +278,31 @@ class DictationOverlay: except Exception: return False + def _display_is_wayland(self) -> bool: + """Check the GType name; GTK3's Gdk typelib does not expose GdkWaylandDisplay as a class.""" + display = self._Gdk.Display.get_default() + if display is None: + return False + try: + return bool(display.__gtype__.name == "GdkWaylandDisplay") + except (AttributeError, TypeError): + return False + + def _mapping_steals_focus(self) -> bool: + """Whether mapping the fallback can steal focus. + + Mutter and other Wayland compositors activate new xdg_toplevels. + Layer-shell surfaces with keyboard mode NONE and X11 override-redirect + windows do not. + """ + return self._display_is_wayland() and not self._use_layer_shell + def set_enabled(self, enabled: bool) -> None: """Enable/disable the feature and sync the window.""" self.controller.set_enabled(enabled) self._sync_window() + if enabled: + self.prewarm() def on_recognition_state(self, state: RecognitionState) -> None: """Handle a recognition state change (same path as the tray icon).""" @@ -332,18 +354,12 @@ class DictationOverlay: self._window.hide() return - # Layout before first map so the first frame is already bottom-center. + # Keep the first visible frame at the correct position. if not self._use_layer_shell: self._layout_bottom_strip() if not self._window.get_visible(): - # Map once; never call present() (steals keyboard focus). - self._window.set_accept_focus(False) - self._window.set_can_focus(False) - self._window.set_focus_on_map(False) - self._window.show_all() - if not self._use_layer_shell: - self._GLib.idle_add(self._layout_bottom_strip_idle) + self._map_passive() try: self._window.set_opacity(1.0) @@ -354,6 +370,44 @@ class DictationOverlay: if self._drawing_area is not None: self._drawing_area.queue_draw() + def _map_passive(self) -> None: + """Map the window without requesting keyboard focus.""" + if self._window is None: + return + # Map once; never call present() (steals keyboard focus). + self._window.set_accept_focus(False) + self._window.set_can_focus(False) + self._window.set_focus_on_map(False) + self._window.show_all() + if not self._use_layer_shell: + self._GLib.idle_add(self._layout_bottom_strip_idle) + + def prewarm(self) -> bool: + """Map the Wayland fallback once while transparent, before recording starts.""" + if ( + not self._gtk_ready + or self._window is None + or not self.controller.enabled + or not self._mapping_steals_focus() + or self._window.get_visible() + ): + return False + + # Layout before first map so the first frame is already bottom-center. + if not self._use_layer_shell: + self._layout_bottom_strip() + + try: + self._window.set_opacity(0.0) + except Exception: + pass + + self._map_passive() + logger.info( + "Dictation overlay mapped at startup so the first recording does not take focus" + ) + return False + def _layout_bottom_strip_idle(self) -> bool: self._layout_bottom_strip() return False # one-shot idle diff --git a/tests/test_dictation_overlay.py b/tests/test_dictation_overlay.py index 8f899ea..53acd3d 100644 --- a/tests/test_dictation_overlay.py +++ b/tests/test_dictation_overlay.py @@ -15,6 +15,7 @@ import sys import tempfile import unittest from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch from vocalinux.common_types import RecognitionState @@ -242,6 +243,134 @@ class TestOverlayWithMockedGtkWindow(unittest.TestCase): proving show/hide follows the real controller without a compositor. """ + def _mock_window_overlay(self, enabled: bool = True) -> tuple[DictationOverlay, MagicMock]: + overlay = _overlay_without_gtk(enabled=enabled) + mock_window = MagicMock() + mock_window.get_visible.return_value = False + overlay._window = mock_window + overlay._gtk_ready = True + overlay._GLib = MagicMock() + overlay._GLib.timeout_add.return_value = 42 + overlay._drawing_area = MagicMock() + return overlay, mock_window + + def test_prewarm_maps_wayland_fallback_transparently_without_animation(self) -> None: + overlay, mock_window = self._mock_window_overlay() + overlay._use_layer_shell = False + + with ( + patch.object(overlay, "_display_is_wayland", return_value=True), + patch.object(overlay, "_layout_bottom_strip"), + ): + self.assertFalse(overlay.prewarm()) + + mock_window.show_all.assert_called_once() + mock_window.set_opacity.assert_called_with(0.0) + mock_window.set_accept_focus.assert_called_with(False) + mock_window.set_can_focus.assert_called_with(False) + mock_window.set_focus_on_map.assert_called_with(False) + self.assertIsNone(overlay._anim_id) + overlay._window = None + overlay.destroy() + + def test_prewarmed_window_is_not_remapped_for_recording(self) -> None: + overlay, mock_window = self._mock_window_overlay() + overlay._use_layer_shell = False + mock_window.is_composited.return_value = True + + with ( + patch.object(overlay, "_display_is_wayland", return_value=True), + patch.object(overlay, "_layout_bottom_strip"), + ): + overlay.prewarm() + mock_window.get_visible.return_value = True + overlay.on_recognition_state(RecognitionState.LISTENING) + + mock_window.show_all.assert_called_once() + mock_window.set_opacity.assert_called_with(1.0) + + overlay.on_recognition_state(RecognitionState.IDLE) + + mock_window.show_all.assert_called_once() + mock_window.hide.assert_not_called() + overlay._window = None + overlay._anim_id = None + overlay.destroy() + + def test_prewarm_is_noop_when_any_gate_blocks_it(self) -> None: + cases = ( + ("X11", False, False, True, True, False), + ("layer shell", True, True, True, True, False), + ("disabled", True, False, False, True, False), + ("already visible", True, False, True, True, True), + ("GTK unavailable", True, False, True, False, False), + ) + for name, wayland, layer_shell, enabled, gtk_ready, visible in cases: + with self.subTest(name=name): + overlay, mock_window = self._mock_window_overlay(enabled=enabled) + overlay._use_layer_shell = layer_shell + overlay._gtk_ready = gtk_ready + mock_window.get_visible.return_value = visible + + with patch.object(overlay, "_display_is_wayland", return_value=wayland): + self.assertFalse(overlay.prewarm()) + + mock_window.show_all.assert_not_called() + mock_window.set_opacity.assert_not_called() + overlay._window = None + overlay.destroy() + + def test_display_is_wayland_for_wayland_gtype(self) -> None: + overlay = _overlay_without_gtk() + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = SimpleNamespace( + __gtype__=SimpleNamespace(name="GdkWaylandDisplay") + ) + + self.assertTrue(overlay._display_is_wayland()) + overlay.destroy() + + def test_display_is_not_wayland_for_x11_gtype(self) -> None: + overlay = _overlay_without_gtk() + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = SimpleNamespace( + __gtype__=SimpleNamespace(name="GdkX11Display") + ) + + self.assertFalse(overlay._display_is_wayland()) + overlay.destroy() + + def test_display_is_not_wayland_without_default_display(self) -> None: + overlay = _overlay_without_gtk() + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = None + + self.assertFalse(overlay._display_is_wayland()) + overlay.destroy() + + def test_display_is_not_wayland_without_gtype(self) -> None: + overlay = _overlay_without_gtk() + overlay._Gdk = MagicMock() + overlay._Gdk.Display.get_default.return_value = SimpleNamespace() + + self.assertFalse(overlay._display_is_wayland()) + overlay.destroy() + + def test_prewarm_returns_false_when_gtk_is_unavailable(self) -> None: + overlay = _overlay_without_gtk() + self.assertFalse(overlay.prewarm()) + overlay.destroy() + + def test_set_enabled_prewarm_only_when_enabled(self) -> None: + overlay = _overlay_without_gtk() + with patch.object(overlay, "prewarm") as mock_prewarm: + overlay.set_enabled(False) + mock_prewarm.assert_not_called() + + overlay.set_enabled(True) + mock_prewarm.assert_called_once_with() + overlay.destroy() + def test_sync_shows_on_listening_hides_on_idle(self): overlay = _overlay_without_gtk(enabled=True) mock_window = MagicMock() @@ -755,6 +884,14 @@ class TestInitGtkWindowWithMocks(unittest.TestCase): self.assertFalse(overlay._use_layer_shell) overlay.destroy() + def test_init_schedules_overlay_prewarm(self) -> None: + mocks = self._install_gi_mocks() + with patch("vocalinux.ui.dictation_overlay._try_import_layer_shell", return_value=None): + overlay = DictationOverlay(enabled=True) + + mocks["glib"].idle_add.assert_called_once_with(overlay.prewarm) + overlay.destroy() + def test_init_with_layer_shell_keyboard_mode(self): shell = _FakeLayerShell(keyboard_mode=True) self._install_gi_mocks()