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:
co-authored by
Jatin K Malik
parent
06f475cf95
commit
d021e64813
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user