diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py index 7daa2f9..38beaf5 100644 --- a/src/vocalinux/ui/dictation_overlay.py +++ b/src/vocalinux/ui/dictation_overlay.py @@ -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). diff --git a/tests/test_dictation_overlay.py b/tests/test_dictation_overlay.py index 53acd3d..85c7bae 100644 --- a/tests/test_dictation_overlay.py +++ b/tests/test_dictation_overlay.py @@ -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()