From 9374b86793c76bbf48b2705228f41109ba94c26e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:28:51 -0700 Subject: [PATCH] fix(ui): keep standalone dialogs from binding to other windows on Wayland (#1024) On GTK's Wayland backend, a toplevel with the DIALOG type hint and no transient parent becomes an orphan dialog: the next toplevel to receive keyboard focus silently becomes its xdg_toplevel parent, and GNOME then raises, minimizes, and workspace-moves the two windows as one. Settings, the transcript viewer, and the log viewer are standalone windows opened parentless, so the dictation pad (focused constantly) adopted them, making Alt+Tab group them and minimize/workspace changes apply to both. Force the NORMAL type hint on all three so they stay independent. Fixes #984 Co-authored-by: Jatin K Malik --- src/vocalinux/ui/logging_dialog.py | 5 +++ src/vocalinux/ui/settings_dialog.py | 7 ++++ src/vocalinux/ui/transcript_dialog.py | 5 +++ tests/test_window_type_hints.py | 51 +++++++++++++++++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 tests/test_window_type_hints.py diff --git a/src/vocalinux/ui/logging_dialog.py b/src/vocalinux/ui/logging_dialog.py index a7ccf59..c6cf297 100644 --- a/src/vocalinux/ui/logging_dialog.py +++ b/src/vocalinux/ui/logging_dialog.py @@ -192,6 +192,11 @@ class LoggingDialog(Gtk.Dialog): flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, modal=False, ) + # Standalone viewer window: without this the DIALOG type hint makes a + # parentless dialog an "orphan" that GTK's Wayland backend binds to the + # next focused toplevel (usually the dictation pad), so the two windows + # raise, minimize and travel workspaces together (#984). + self.set_type_hint(Gdk.WindowTypeHint.NORMAL) self.logging_manager = get_logging_manager() self.auto_scroll = True diff --git a/src/vocalinux/ui/settings_dialog.py b/src/vocalinux/ui/settings_dialog.py index 2cba7a1..e51cec3 100644 --- a/src/vocalinux/ui/settings_dialog.py +++ b/src/vocalinux/ui/settings_dialog.py @@ -2525,6 +2525,13 @@ class SettingsDialog(AboutSectionMixin): # has a visible way to dismiss it, even on window managers that hide # the title-bar close button for Gtk.Dialog windows (fixes #323). self.set_decorated(True) + # A parentless Gtk.Dialog carries the DIALOG type hint, which lands it + # on GTK's Wayland "orphan dialog" list: the next toplevel to receive + # keyboard focus silently becomes its xdg_toplevel parent, and GNOME + # then minimizes, raises and moves the two windows as a unit (#984). + # Settings is a standalone window, so keep the NORMAL hint and stay + # independent of the dictation pad and overlay. + self.set_type_hint(Gdk.WindowTypeHint.NORMAL) self.config_manager = config_manager self.speech_engine = speech_engine diff --git a/src/vocalinux/ui/transcript_dialog.py b/src/vocalinux/ui/transcript_dialog.py index b23a9c4..10709d4 100644 --- a/src/vocalinux/ui/transcript_dialog.py +++ b/src/vocalinux/ui/transcript_dialog.py @@ -82,6 +82,11 @@ class TranscriptDialog(Gtk.Dialog): flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, modal=False, ) + # Standalone viewer window: without this the DIALOG type hint makes a + # parentless dialog an "orphan" that GTK's Wayland backend binds to the + # next focused toplevel (usually the dictation pad), so the two windows + # raise, minimize and travel workspaces together (#984). + self.set_type_hint(Gdk.WindowTypeHint.NORMAL) _setup_css() self.set_default_size(720, 560) diff --git a/tests/test_window_type_hints.py b/tests/test_window_type_hints.py new file mode 100644 index 0000000..3666446 --- /dev/null +++ b/tests/test_window_type_hints.py @@ -0,0 +1,51 @@ +""" +Regression tests for #984: standalone windows must not carry the DIALOG +type hint. + +On GTK's Wayland backend, a toplevel with the DIALOG hint and no transient +parent becomes an "orphan dialog": the next toplevel in the app to receive +keyboard focus silently becomes its xdg_toplevel parent. GNOME then treats +the pair as one window for minimize/restore, raising, and workspace moves. + +The dictation pad is a plain Gtk.Window (NORMAL hint) that is focused +constantly, so any standalone Gtk.Dialog left parentless gets bound to it. +Settings, the transcript viewer, and the log viewer are standalone windows +and must opt out with the NORMAL hint. +""" + +import os +import unittest + + +def _read_source(filename: str) -> str: + """Read a UI module's source code.""" + source_path = os.path.join(os.path.dirname(__file__), "..", "src", "vocalinux", "ui", filename) + with open(source_path, "r") as f: + return f.read() + + +class TestStandaloneDialogsUseNormalHint(unittest.TestCase): + """Parentless standalone dialogs must not stay DIALOG-hinted (#984).""" + + def _assert_normal_hint(self, filename: str, class_name: str) -> None: + source = _read_source(filename) + class_start = source.index(f"class {class_name}(") + init_start = source.index("def __init__", class_start) + # The hint must be reset inside __init__ before the window is shown. + init_end = source.index("\n def ", init_start + 1) + init_source = source[init_start:init_end] + self.assertIn( + "set_type_hint(Gdk.WindowTypeHint.NORMAL)", + init_source, + f"{class_name} must opt out of the DIALOG type hint so GTK's " + "Wayland backend never adopts it as an orphan dialog (#984)", + ) + + def test_settings_dialog_uses_normal_hint(self) -> None: + self._assert_normal_hint("settings_dialog.py", "SettingsDialog") + + def test_transcript_dialog_uses_normal_hint(self) -> None: + self._assert_normal_hint("transcript_dialog.py", "TranscriptDialog") + + def test_logging_dialog_uses_normal_hint(self) -> None: + self._assert_normal_hint("logging_dialog.py", "LoggingDialog")