fix(dictation-pad): keep pad window out of the compositor ghost state (#912)

* fix(dictation-pad): keep pad window out of the compositor ghost state

On GNOME Wayland an idle, fully covered toplevel gets shelved by the
compositor: it stays in the window list but renders nothing and takes
no input. Keep the pad floating above other windows and pinned to all
workspaces so it cannot be shelved, track iconify/obscure state events,
deiconify on show, and repaint when visibility returns.

Fixes #896

* test(dictation-pad): scope Window return_value patch in shelving test

* fix(dictation-pad): detect Wayland shelving and recreate dead surface

set_keep_above is a stub in GTK3's Wayland backend, so a covered idle pad
still gets shelved by the compositor on GNOME/Wayland: it stays in the
window list but draws nothing and takes no input, and present() cannot
revive it.

Add a frame-callback watchdog: each tick callback stamps the clock and a
GLib timeout flags a mapped window whose frames went cold as shelved. A
shelved pad is then recreated (hide + show_all) in show_pad, the same
path the user's manual Close + tray reopen takes.

Keep keep_above/stick for X11 where they are honored, plus the iconify
and visibility-event handling from the previous commit.

---------

Co-authored-by: Jatin K Malik <jatinkrmalik@gmail.com>
This commit is contained in:
devin-ai-integration[bot]
2026-10-07 18:07:22 -07:00
committed by GitHub
co-authored by Jatin K Malik
parent 06f475cf95
commit d021e64813
2 changed files with 310 additions and 0 deletions
+106
View File
@@ -26,6 +26,11 @@ _PAD_WIDTH = 480
_PAD_HEIGHT = 360
_COPIED_FEEDBACK_MS = 1200
_UNDO_LIMIT = 200
# Frame-callback watchdog for the Wayland shelving state (#896): a covered
# toplevel stops receiving frame callbacks while the client still reports it
# mapped. >1.5 s of silence on a visible window is treated as shelved.
_SHELF_WATCH_MS = 2000
_SHELF_STALE_US = 1_500_000
def _ui_errors() -> tuple[type[BaseException], ...]:
@@ -209,6 +214,15 @@ class DictationPad:
self._gtk_ready = False
self._syncing_capture_check = False
self._syncing_widget = False
# Compositor-side shelving state. _window_iconified is tracked from
# window-state-event (X11 minimize); _surface_shelved is detected by
# the frame-callback watchdog (Wayland shelving); _window_obscured is
# tracked from visibility-notify-event where GTK reports it.
self._window_iconified = False
self._window_obscured = False
self._surface_shelved = False
self._last_frame_ts = 0
self._shelf_watch_id: Optional[int] = None
# Monotonic tag stamped on every queued widget op. A Clear (or a full
# refresh) bumps it, so appends still waiting in the GTK idle queue
# can tell they are stale and must not resurrect removed text.
@@ -246,6 +260,20 @@ class DictationPad:
# and must not take the app down with it.
window.connect("delete-event", self._on_delete_event)
# The pad is a utility window users dictate into while working in
# other apps: float it above other windows and pin it to every
# workspace. On X11 that stops the window being fully covered at all.
# Wayland has no always-on-top protocol (both calls are no-ops there),
# so a covered idle pad gets shelved by the compositor — it stays in
# the window list but draws nothing and takes no input (#896). The
# frame-callback watchdog below detects that state; show_pad then
# recreates the surface to revive it.
window.set_keep_above(True)
window.stick()
window.add_events(Gdk.EventMask.VISIBILITY_NOTIFY_MASK)
window.connect("window-state-event", self._on_window_state_event)
window.connect("visibility-notify-event", self._on_visibility_notify)
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
outer.set_margin_start(12)
outer.set_margin_end(12)
@@ -288,6 +316,12 @@ class DictationPad:
self._buffer.connect("delete-range", self._on_buffer_user_edit)
scrolled.add(self._textview)
# Frame-callback watchdog: each compositor frame callback stamps the
# clock; the timeout below flags a mapped window whose frames went
# cold as shelved so show_pad can revive it.
self._textview.add_tick_callback(self._on_frame_tick)
self._shelf_watch_id = GLib.timeout_add(_SHELF_WATCH_MS, self._shelf_watchdog)
button_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
button_row.set_halign(Gtk.Align.END)
outer.pack_start(button_row, False, False, 0)
@@ -341,6 +375,25 @@ class DictationPad:
self._sync_capture_check()
if not self._window.get_visible():
self._window.show_all()
elif self._surface_shelved:
# The compositor shelved the surface: present() alone cannot
# revive it (the activation request is ignored for a dead
# surface). Recreate it the way the user's manual Close + tray
# reopen does: hide() drops the wl_surface, show_all() maps a
# fresh one that the compositor treats as a live window.
try:
self._window.hide()
self._window.show_all()
self._surface_shelved = False
except _ui_errors() as e:
logger.debug("Could not recreate dictation pad surface: %s", e)
elif self._window_iconified:
# The window manager shelved the pad (iconified/withdrawn) while
# it stayed mapped: ask it to restore before presenting.
try:
self._window.deiconify()
except _ui_errors() as e:
logger.debug("Could not restore dictation pad: %s", e)
try:
self._window.present_with_time(self._Gtk.get_current_event_time())
except _ui_errors():
@@ -362,6 +415,12 @@ class DictationPad:
except _ui_errors():
pass
self._copied_feedback_id = None
if self._shelf_watch_id is not None:
try:
self._GLib.source_remove(self._shelf_watch_id)
except _ui_errors():
pass
self._shelf_watch_id = None
if self._window is not None:
try:
self._window.destroy()
@@ -588,6 +647,53 @@ class DictationPad:
self._window.hide()
return True
def _on_window_state_event(self, _widget: Any, event: Any) -> bool:
"""Track the window manager shelving the pad (iconified/withdrawn)."""
shelved = self._Gdk.WindowState.ICONIFIED | self._Gdk.WindowState.WITHDRAWN
self._window_iconified = bool(event.new_window_state & shelved)
return False
def _on_frame_tick(self, _widget: Any, _clock: Any) -> bool:
"""Stamp the time of the last compositor frame callback."""
self._last_frame_ts = self._GLib.get_monotonic_time()
return True
def _shelf_watchdog(self) -> bool:
"""Flag a mapped window whose frame callbacks went cold as shelved.
Wayland gives a GTK3 client no signal for shelving — the GdkWindow
still reports mapped and visible — but the compositor stops issuing
frame callbacks. Staleness is only meaningful after the first frame:
a window that never drew yet is not proof of shelving.
"""
if self._window is None or not self._window.get_visible():
self._surface_shelved = False
return True
if self._last_frame_ts == 0:
return True
self._surface_shelved = (
self._GLib.get_monotonic_time() - self._last_frame_ts > _SHELF_STALE_US
)
return True
def _on_visibility_notify(self, _widget: Any, event: Any) -> bool:
"""Repaint the pad when the compositor reports it visible again.
GTK3 on Wayland marks a shelved toplevel fully obscured; when it
comes back the last committed buffer may be stale, so force one
fresh draw instead of trusting what is on screen.
"""
if event.state == self._Gdk.VisibilityState.FULLY_OBSCURED:
self._window_obscured = True
elif self._window_obscured:
self._window_obscured = False
if self._window is not None:
try:
self._window.queue_draw()
except _ui_errors() as e:
logger.debug("Could not repaint dictation pad: %s", e)
return False
def _on_capture_toggled(self, widget: Any) -> None:
"""Persist the pad's own capture checkbox."""
if self._syncing_capture_check:
+204
View File
@@ -8,9 +8,12 @@ Important: these tests must not import real GTK / tray_indicator into
sys.modules — that breaks later tests that mock gi (see CI isolation).
"""
import ast
import os
import sys
import tempfile
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from vocalinux.ui.config_manager import DEFAULT_CONFIG, ConfigManager
@@ -802,5 +805,206 @@ class TestDictationPadFacade(unittest.TestCase):
pad.destroy()
def _pad_source() -> str:
"""Read dictation_pad.py so GTK-facing methods can be exec'd under fake gi."""
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"src",
"vocalinux",
"ui",
"dictation_pad.py",
)
with open(path, encoding="utf-8") as f:
return f.read()
def _fake_gdk() -> SimpleNamespace:
"""Gdk enums the pad handlers read, with real GDK numeric values."""
return SimpleNamespace(
WindowState=SimpleNamespace(WITHDRAWN=1, ICONIFIED=2),
VisibilityState=SimpleNamespace(UNOBSCURED=0, PARTIAL=1, FULLY_OBSCURED=2),
)
class TestDictationPadShelving(unittest.TestCase):
"""
Compositor-shelving handling for the pad window (issue #896).
On GNOME/Wayland an idle, fully covered toplevel gets shelved by the
compositor: it stays in the window list but draws nothing and accepts
no input, and present() alone cannot revive it. The pad counters that
by floating above other windows on X11, detecting the shelved state
via frame-callback staleness, and recreating the surface on show.
"""
def test_init_gtk_window_floats_and_watches_shelving(self) -> None:
"""Window construction pins the pad and subscribes shelving events."""
window = MagicMock()
tree = ast.parse(_pad_source())
func_node = next(
node
for cls in tree.body
if isinstance(cls, ast.ClassDef) and cls.name == "DictationPad"
for node in cls.body
if isinstance(node, ast.FunctionDef) and node.name == "_init_gtk_window"
)
module = ast.Module(body=[func_node], type_ignores=[])
namespace: dict = {"_PAD_WIDTH": 480, "_PAD_HEIGHT": 360, "_SHELF_WATCH_MS": 2000}
exec(compile(ast.fix_missing_locations(module), "<test>", "exec"), namespace)
fake_self = MagicMock()
# gi.repository is a shared session mock; scope the Window return value
# so no other test inherits this window or its recorded calls.
gtk_window = sys.modules["gi.repository"].Gtk.Window
with patch.object(gtk_window, "return_value", window):
namespace["_init_gtk_window"](fake_self)
window.set_keep_above.assert_called_once_with(True)
window.stick.assert_called_once()
window.add_events.assert_called_once()
connected = {call.args[0] for call in window.connect.call_args_list}
self.assertIn("window-state-event", connected)
self.assertIn("visibility-notify-event", connected)
# Frame-callback watchdog: tick callback on the textview plus a
# recurring GLib timeout running _shelf_watchdog.
fake_self._textview.add_tick_callback.assert_called_once_with(fake_self._on_frame_tick)
fake_self._GLib.timeout_add.assert_called_once_with(2000, fake_self._shelf_watchdog)
def test_window_state_event_tracks_shelving(self) -> None:
"""ICONIFIED or WITHDRAWN in new_window_state marks the pad shelved."""
pad = _pad_without_gtk()
try:
pad._Gdk = _fake_gdk()
widget = MagicMock()
pad._on_window_state_event(widget, SimpleNamespace(new_window_state=2))
self.assertTrue(pad._window_iconified)
pad._on_window_state_event(widget, SimpleNamespace(new_window_state=0))
self.assertFalse(pad._window_iconified)
pad._on_window_state_event(widget, SimpleNamespace(new_window_state=1))
self.assertTrue(pad._window_iconified)
finally:
pad.destroy()
def test_show_pad_deiconifies_shelved_window(self) -> None:
"""A visible but iconified pad is restored before it is presented."""
pad = _pad_without_gtk()
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad._Gtk = MagicMock()
pad._window_iconified = True
pad.show_pad()
pad._window.deiconify.assert_called_once()
pad._window.present_with_time.assert_called_once()
pad._window.reset_mock()
pad._window_iconified = False
pad.show_pad()
pad._window.deiconify.assert_not_called()
pad._window.present_with_time.assert_called_once()
finally:
pad._window = None
pad.destroy()
def test_frame_tick_stamps_monotonic_time(self) -> None:
"""Each compositor frame callback updates the liveness stamp."""
pad = _pad_without_gtk()
try:
pad._GLib = MagicMock()
pad._GLib.get_monotonic_time.return_value = 123_000_000
self.assertTrue(pad._on_frame_tick(MagicMock(), MagicMock()))
self.assertEqual(pad._last_frame_ts, 123_000_000)
finally:
pad.destroy()
def test_shelf_watchdog_flags_stale_surface(self) -> None:
"""A visible window with dead frame callbacks is marked shelved."""
pad = _pad_without_gtk()
try:
pad._GLib = MagicMock()
pad._GLib.get_monotonic_time.return_value = 10_000_000
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad._last_frame_ts = 0
pad._shelf_watchdog()
self.assertFalse(pad._surface_shelved) # never drawn: not proof
pad._last_frame_ts = 8_800_000 # 1.2 s stale: still alive
pad._shelf_watchdog()
self.assertFalse(pad._surface_shelved)
pad._last_frame_ts = 8_000_000 # 2.0 s stale: shelved
pad._shelf_watchdog()
self.assertTrue(pad._surface_shelved)
pad._window.get_visible.return_value = False
pad._shelf_watchdog()
self.assertFalse(pad._surface_shelved) # hidden: not shelved
finally:
pad._window = None
pad.destroy()
def test_show_pad_recreates_shelved_surface(self) -> None:
"""A shelved pad is revived by hide+show_all, not present() alone."""
pad = _pad_without_gtk()
try:
pad._gtk_ready = True
pad._window = MagicMock()
pad._window.get_visible.return_value = True
pad._Gtk = MagicMock()
pad._surface_shelved = True
pad.show_pad()
pad._window.hide.assert_called_once()
pad._window.show_all.assert_called_once()
self.assertFalse(pad._surface_shelved)
pad._window.present_with_time.assert_called_once()
pad._window.deiconify.assert_not_called()
finally:
pad._window = None
pad.destroy()
def test_destroy_removes_shelf_watchdog(self) -> None:
"""The watchdog timeout is removed when the pad is destroyed."""
pad = _pad_without_gtk()
pad._GLib = MagicMock()
pad._shelf_watch_id = 77
pad.destroy()
pad._GLib.source_remove.assert_any_call(77)
self.assertIsNone(pad._shelf_watch_id)
def test_visibility_notify_repaints_only_after_obscured(self) -> None:
"""Return from full occlusion forces one fresh draw, once."""
pad = _pad_without_gtk()
try:
pad._Gdk = _fake_gdk()
pad._window = MagicMock()
widget = MagicMock()
obscured = SimpleNamespace(state=2) # FULLY_OBSCURED
visible = SimpleNamespace(state=0) # UNOBSCURED
pad._on_visibility_notify(widget, visible)
pad._window.queue_draw.assert_not_called()
pad._on_visibility_notify(widget, obscured)
pad._on_visibility_notify(widget, visible)
pad._window.queue_draw.assert_called_once()
pad._window.reset_mock()
pad._on_visibility_notify(widget, visible)
pad._window.queue_draw.assert_not_called()
finally:
pad._window = None
pad.destroy()
if __name__ == "__main__":
unittest.main()