* fix(overlay): quit Vocalinux when the dock closes the overlay window (#1028) On GNOME Wayland without layer shell, the prewarmed dictation overlay is a regular toplevel that the dock lists as the app's window. Since #1017 the overlay refuses close requests so it is never destroyed (#1010), which made "Quit" from the dock silently do nothing. The overlay still keeps its window, but now forwards the close request to an optional on_close_request callback, scheduled once from an idle source. The tray passes its _quit(), so the dock's Quit behaves like the tray's Quit. * fix(overlay): drop a queued dock close once the app is quitting A dock close request is forwarded through an idle source. If the tray's Quit or SIGTERM tears the overlay down before that idle runs, the callback would run _quit() a second time. destroy() now drops the callback, and the pending flag is cleared when the request runs so a later close can retry if quitting failed. Refs #1028
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user