diff --git a/src/vocalinux/ui/dictation_pad.py b/src/vocalinux/ui/dictation_pad.py index d1d5fd0..901ae13 100644 --- a/src/vocalinux/ui/dictation_pad.py +++ b/src/vocalinux/ui/dictation_pad.py @@ -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: diff --git a/tests/test_dictation_pad.py b/tests/test_dictation_pad.py index f3b4340..ab8ed5b 100644 --- a/tests/test_dictation_pad.py +++ b/tests/test_dictation_pad.py @@ -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), "", "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()