diff --git a/src/vocalinux/ui/dictation_overlay.py b/src/vocalinux/ui/dictation_overlay.py index 38beaf5..fca581e 100644 --- a/src/vocalinux/ui/dictation_overlay.py +++ b/src/vocalinux/ui/dictation_overlay.py @@ -11,7 +11,7 @@ from __future__ import annotations import logging import math from types import ModuleType -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Callable, Optional if TYPE_CHECKING: from gi.repository import Gdk, Gtk @@ -120,8 +120,15 @@ class DictationOverlay: window is passive: no keyboard focus, click-through input region. """ - def __init__(self, enabled: bool = True) -> None: + def __init__( + self, + enabled: bool = True, + on_close_request: Optional[Callable[[], None]] = None, + ) -> None: self.controller = DictationOverlayController(enabled=enabled) + # Called when the dock or taskbar asks to close the window (#1028). + self._on_close_request = on_close_request + self._close_request_pending = False self._window: Optional[Gtk.Window] = None self._drawing_area: Optional[Gtk.DrawingArea] = None self._anim_id: Optional[int] = None @@ -263,13 +270,34 @@ class DictationOverlay: 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. + + The window stays, but the request is passed to ``on_close_request`` + so "Quit" from the dock quits Vocalinux like the tray's Quit (#1028). """ - logger.info( - "Ignoring a close request for the dictation overlay; " - "turn the overlay off in Settings to remove it" - ) + if self._on_close_request is None: + logger.info( + "Ignoring a close request for the dictation overlay; " + "turn the overlay off in Settings to remove it" + ) + return True + if not self._close_request_pending: + logger.info("Dock or taskbar asked to close the dictation overlay; quitting") + self._close_request_pending = True + self._GLib.idle_add(self._run_close_request) return True + def _run_close_request(self) -> bool: + """Run the close callback from the main loop (one-shot idle source). + + ``destroy()`` drops the callback, so a request still queued when the + app is already quitting (tray Quit, SIGTERM) does not quit it twice. + """ + self._close_request_pending = False + callback = self._on_close_request + if callback is not None: + callback() + return False + def _on_size_allocate(self, widget, allocation) -> None: """Keep the empty input shape after size changes.""" if widget.get_realized(): @@ -336,6 +364,8 @@ class DictationOverlay: def destroy(self) -> None: """Tear down animation and window.""" + # Teardown is underway; a queued dock close must not run the callback. + self._on_close_request = None self._stop_animation() if self._window is not None: try: diff --git a/src/vocalinux/ui/tray_indicator.py b/src/vocalinux/ui/tray_indicator.py index 14aa5c2..81435ca 100644 --- a/src/vocalinux/ui/tray_indicator.py +++ b/src/vocalinux/ui/tray_indicator.py @@ -278,7 +278,11 @@ class TrayIndicator: # Lazy import keeps tray import light for tests that mock gi. from .dictation_overlay import DictationOverlay - self.overlay = DictationOverlay(enabled=self.config_manager.is_overlay_enabled()) + # Quit from the dock closes the overlay toplevel; treat it as Quit (#1028). + self.overlay = DictationOverlay( + enabled=self.config_manager.is_overlay_enabled(), + on_close_request=self._quit, + ) self.speech_engine.register_audio_level_callback(self.overlay.set_audio_level) # Initialize the icon files and validate resources diff --git a/tests/test_dictation_overlay.py b/tests/test_dictation_overlay.py index 85c7bae..72459a9 100644 --- a/tests/test_dictation_overlay.py +++ b/tests/test_dictation_overlay.py @@ -382,6 +382,67 @@ class TestOverlayWithMockedGtkWindow(unittest.TestCase): overlay._window = None overlay.destroy() + def test_dock_quit_runs_close_request_callback(self) -> None: + """Quit from GNOME's dock closes the overlay toplevel; it must quit the app (#1028).""" + on_close = MagicMock() + with patch.object( + DictationOverlay, + "_init_gtk_window", + side_effect=RuntimeError("gtk disabled in unit tests"), + ): + overlay = DictationOverlay(enabled=True, on_close_request=on_close) + mock_window = MagicMock() + overlay._window = mock_window + overlay._gtk_ready = True + overlay._GLib = MagicMock() + + # The window is still kept alive (#1010), but the request is forwarded. + self.assertTrue(overlay._on_delete_event(mock_window, MagicMock())) + mock_window.destroy.assert_not_called() + overlay._GLib.idle_add.assert_called_once() + on_close.assert_not_called() + + # A second close (the dock closes every window) is not queued twice. + self.assertTrue(overlay._on_delete_event(mock_window, MagicMock())) + overlay._GLib.idle_add.assert_called_once() + + scheduled = overlay._GLib.idle_add.call_args.args[0] + self.assertFalse(scheduled()) + on_close.assert_called_once_with() + overlay._window = None + overlay.destroy() + + def test_queued_close_request_is_dropped_once_overlay_is_destroyed(self) -> None: + """A dock close queued while the app is already quitting must not quit again.""" + on_close = MagicMock() + with patch.object( + DictationOverlay, + "_init_gtk_window", + side_effect=RuntimeError("gtk disabled in unit tests"), + ): + overlay = DictationOverlay(enabled=True, on_close_request=on_close) + mock_window = MagicMock() + overlay._window = mock_window + overlay._gtk_ready = True + overlay._GLib = MagicMock() + + overlay._on_delete_event(mock_window, MagicMock()) + scheduled = overlay._GLib.idle_add.call_args.args[0] + + # Tray Quit (or SIGTERM) tears the overlay down before the idle runs. + overlay.destroy() + self.assertFalse(scheduled()) + on_close.assert_not_called() + + def test_close_request_without_callback_schedules_nothing(self) -> None: + overlay, mock_window = self._mock_window_overlay() + + self.assertTrue(overlay._on_delete_event(mock_window, MagicMock())) + + overlay._GLib.idle_add.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() diff --git a/tests/test_tray_indicator.py b/tests/test_tray_indicator.py index b837a8e..da82d81 100644 --- a/tests/test_tray_indicator.py +++ b/tests/test_tray_indicator.py @@ -1219,6 +1219,10 @@ class TestTrayIndicator(unittest.TestCase): pad.show_pad, ) + def test_overlay_close_request_quits_app(self): + """A dock/taskbar close of the overlay window quits Vocalinux (#1028).""" + self.assertEqual(self.tray_indicator.overlay._on_close_request, self.tray_indicator._quit) + def test_quit_destroys_overlay(self): mock_overlay = MagicMock() self.tray_indicator.overlay = mock_overlay