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.
This commit is contained in:
Jatin K Malik
2026-10-20 23:45:57 -07:00
committed by GitHub
parent d59395b5c9
commit 545b6d0feb
2 changed files with 199 additions and 8 deletions
+62 -8
View File
@@ -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
+137
View File
@@ -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()