From 545b6d0feb9bac9a2a2afac93a7211b19e61f501 Mon Sep 17 00:00:00 2001 From: Jatin K Malik Date: Tue, 20 Oct 2026 23:45:57 -0700 Subject: [PATCH] fix(overlay): map the dictation overlay at startup on Wayland so the first recording keeps focus (#998) * fix(overlay): map the dictation overlay at startup on Wayland Mutter activates newly mapped Wayland toplevels, which can take focus from the editor. Map the fallback overlay transparently at startup so its first recording does not remap it. Fixes #992 * fix(overlay): detect the Wayland display by GType name for the prewarm Detect Wayland from the display GType because GTK3 omits GdkWaylandDisplay from its typelib. --- src/vocalinux/ui/dictation_overlay.py | 70 +++++++++++-- tests/test_dictation_overlay.py | 137 ++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 8 deletions(-) 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()