fix(overlay): keep the Wayland fallback overlay alive when the dock closes it (#1017)

Refuse close requests on the pre-mapped Wayland overlay so closing its GNOME dock entry no longer destroys it and brings back the first-recording focus loss, and unmap the overlay when it is turned off so the dock entry goes away immediately.

Fixes #1010
This commit is contained in:
Jatin K Malik
2026-10-25 14:49:15 -07:00
committed by GitHub
parent 2b2291ce69
commit ed29ddf84b
2 changed files with 72 additions and 1 deletions
+24 -1
View File
@@ -14,7 +14,7 @@ from types import ModuleType
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from gi.repository import Gtk
from gi.repository import Gdk, Gtk
from ..common_types import RecognitionState
@@ -209,6 +209,7 @@ class DictationOverlay:
window.connect("size-allocate", self._on_size_allocate)
window.connect("realize", self._on_realize)
window.connect("map-event", self._on_map_event)
window.connect("delete-event", self._on_delete_event)
# Fallback placement is stale the moment monitor geometry or the
# compositor changes; layer shell re-anchors itself on these signals.
screen.connect("size-changed", self._on_screen_geometry_changed)
@@ -254,6 +255,21 @@ class DictationOverlay:
self._on_realize(widget)
return False
def _on_delete_event(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
"""Refuse close requests from the dock or taskbar.
On Wayland without layer shell the overlay is a regular toplevel, so
GNOME's dock lists it and can close it. GTK's default handler would
destroy the window; the next recording would then map a new toplevel
that takes focus from the editor (#1010). Disabling the overlay in
Settings unmaps it instead.
"""
logger.info(
"Ignoring a close request for the dictation overlay; "
"turn the overlay off in Settings to remove it"
)
return True
def _on_size_allocate(self, widget, allocation) -> None:
"""Keep the empty input shape after size changes."""
if widget.get_realized():
@@ -336,6 +352,13 @@ class DictationOverlay:
mode = self.controller.mode
if mode is None:
self._stop_animation()
if not self.controller.enabled:
# Turned off: nothing maps the window again until it is
# re-enabled, so unmap it. A transparent mapped window still
# shows in the dock on Wayland without layer shell (#1010).
if self._window.get_visible():
self._window.hide()
return
# Opacity 0 instead of hide() — hide/show cycles re-activate the
# window on many Wayland compositors and steal editor focus (which
# breaks IBus/wtype injection into the previously focused app).
+48
View File
@@ -371,6 +371,53 @@ class TestOverlayWithMockedGtkWindow(unittest.TestCase):
mock_prewarm.assert_called_once_with()
overlay.destroy()
def test_close_request_keeps_overlay_window(self) -> None:
"""A close from GNOME's dock must not destroy the prewarmed window (#1010)."""
overlay, mock_window = self._mock_window_overlay()
self.assertTrue(overlay._on_delete_event(mock_window, MagicMock()))
mock_window.destroy.assert_not_called()
mock_window.hide.assert_not_called()
overlay._window = None
overlay.destroy()
def test_disabling_unmaps_prewarmed_window(self) -> None:
"""Turning the overlay off removes the transparent window from the dock (#1010)."""
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.set_enabled(False)
mock_window.hide.assert_called_once()
mock_window.get_visible.return_value = False
overlay.set_enabled(True)
self.assertEqual(mock_window.show_all.call_count, 2)
mock_window.set_opacity.assert_called_with(0.0)
overlay._window = None
overlay.destroy()
def test_idle_keeps_enabled_window_mapped_when_composited(self) -> None:
overlay, mock_window = self._mock_window_overlay()
mock_window.get_visible.return_value = True
mock_window.is_composited.return_value = True
overlay.on_recognition_state(RecognitionState.IDLE)
mock_window.hide.assert_not_called()
mock_window.set_opacity.assert_called_with(0.0)
overlay._window = None
overlay.destroy()
def test_sync_shows_on_listening_hides_on_idle(self):
overlay = _overlay_without_gtk(enabled=True)
mock_window = MagicMock()
@@ -881,6 +928,7 @@ class TestInitGtkWindowWithMocks(unittest.TestCase):
win.add.assert_called_with(mocks["drawing"])
mocks["drawing"].connect.assert_any_call("draw", overlay._on_draw)
win.connect.assert_any_call("realize", overlay._on_realize)
win.connect.assert_any_call("delete-event", overlay._on_delete_event)
self.assertFalse(overlay._use_layer_shell)
overlay.destroy()