fix(overlay): quit Vocalinux when the dock closes the overlay window (#1028) (#1036)

* 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:
Jatin K Malik
2026-11-02 19:17:21 -08:00
committed by GitHub
parent 61bfb1e58e
commit f70357f2b8
4 changed files with 106 additions and 7 deletions
+36 -6
View File
@@ -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:
+5 -1
View File
@@ -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
+61
View File
@@ -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()
+4
View File
@@ -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