From 341c52e3801303c5991dd59d1c63452f4e6b9ddf Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 10 Sep 2026 14:15:46 +0200 Subject: [PATCH] fix(gui): restore foreground requests and scope raising workaround to xcb --- README.md | 12 +++-- bec_widgets/cli/client_utils.py | 16 ++---- bec_widgets/utils/rpc_server.py | 50 ++++++++++++++---- tests/unit_tests/test_client_utils.py | 13 ++--- tests/unit_tests/test_rpc_server.py | 74 +++++++++++++++++++++++++-- 5 files changed, 126 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index a9b8f766..d71735d8 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,14 @@ active devices, topics)—so your UI can be heavily automated. #### Window visibility and display diagnostics -`gui.show()` shows hidden windows and restores minimized windows. Repeating it leaves visible -windows unchanged. Use `gui.raise_window()` or `gui.flomni.raise_window()` to additionally request -foreground activation. Activation is subject to the window manager/compositor's focus policy, -particularly on Wayland; a Redis RPC is not a local mouse or keyboard event. +`gui.show()` shows hidden windows, restores minimized windows, and requests foreground activation +when a window is behind another application. `gui.raise_window()` uses the same behaviour; +`gui.flomni.raise_window()` targets one window. Already-visible, active windows are left unchanged. + +On XCB (X11/XWayland), BEC retains its GNOME/RHEL window-raising workaround and restores the original +window flags afterwards. On native Wayland and macOS it requests activation without changing flags. +Activation is subject to the window manager/compositor's focus policy, particularly on Wayland; +a Redis RPC is not a local mouse or keyboard event. To inspect the **running GUI server process**, from the BEC IPython client: diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 8307fed6..f0b6a0f4 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -416,15 +416,15 @@ class BECGuiClient(RPCBase): def show(self, wait=True) -> None: """ - Show GUI windows without explicitly requesting focus. - Already-visible windows are left unchanged; minimized windows are restored. + Show GUI windows and request foreground activation, including for visible windows + behind another application. Already-active windows are left unchanged. If the GUI server is not running, it will be started. Args: wait(bool): Whether to wait for the server to start. Defaults to True. """ if self._check_if_server_is_alive(): - return self._show_all() + return self._raise_all() return self._start(wait=wait) def hide(self): @@ -741,16 +741,6 @@ class BECGuiClient(RPCBase): self._server_registry = cast(dict[str, RegistryState], msg["data"].state) self._update_dynamic_namespace(self._server_registry) - def _do_show_all(self): - if self.launcher and len(self._top_level) == 0: - self.launcher._run_rpc("show") # pylint: disable=protected-access - for window in self._top_level.values(): - window._run_rpc("show") - - def _show_all(self): - with wait_for_server(self): - return self._do_show_all() - def _hide_all(self): with wait_for_server(self): if self._killed: diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index d0dd26e3..ee47e303 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -235,18 +235,15 @@ class RPCServer: # Run with rpc registry broadcast, but only once with RPCRegister.delayed_broadcast(): logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}") - if method in ("show", "raise") and isinstance(obj, QWidget): - window = obj.window() if method == "raise" else obj - if window.isMinimized(): + if method == "raise" and isinstance(obj, QWidget): + self._raise_window(obj.window()) + res = None + elif method == "show" and isinstance(obj, QWidget): + if obj.isMinimized(): # Preserve maximized/full-screen state when restoring a minimized window. - window.setWindowState(window.windowState() & ~Qt.WindowMinimized) - if not window.isVisible(): - window.show() - if method == "raise": - # Changing window flags hides/recreates native windows. Request activation - # without that workaround; the window manager/compositor decides focus. - window.raise_() - window.activateWindow() + obj.setWindowState(obj.windowState() & ~Qt.WindowMinimized) + if not obj.isVisible(): + obj.show() res = None else: target_obj, method_obj = self._resolve_rpc_target(obj, method) @@ -261,6 +258,37 @@ class RPCServer: res = method_obj(*args, **kwargs) return res + @staticmethod + def _raise_window(window: QWidget) -> None: + """Restore a window and request focus using its loaded Qt platform backend.""" + if window.isVisible() and window.isActiveWindow() and not window.isMinimized(): + return + + state = window.windowState() & ~Qt.WindowMinimized + if QApplication.platformName() == "xcb": + # Keep the GNOME/RHEL X11 workaround that also works through XWayland. + # Never run it on native Wayland: flag changes hide/recreate the surface. + flags = window.windowFlags() + window.setWindowState(state | Qt.WindowActive) + try: + window.setWindowFlags(flags | Qt.WindowStaysOnTopHint) + window.raise_() + window.activateWindow() + finally: + # Preserve intentional always-on-top flags as well as all other flags. + window.setWindowFlags(flags) + window.show() + return + + if window.isMinimized(): + window.setWindowState(state) + if not window.isVisible(): + window.show() + # Qt requests activation from the compositor on Wayland. A visible window can + # still be behind another application, so visibility alone must not skip this. + window.raise_() + window.activateWindow() + def _resolve_rpc_target(self, obj, method: str) -> tuple[object, object]: """ Resolve a method/property access target for RPC execution. diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 21ee7fb0..a9c5f623 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -135,9 +135,9 @@ def _no_wait_for_server(_client): yield -@pytest.mark.parametrize("method, action", [("show", "show"), ("raise_window", "raise")]) +@pytest.mark.parametrize("method", ["show", "raise_window"]) @pytest.mark.parametrize("window_names", [(), ("bec", "flomni")]) -def test_client_show_and_raise_use_distinct_actions(method, action, window_names): +def test_client_show_and_raise_request_foreground_activation(method, window_names): gui = BECGuiClient() windows = {name: mock.MagicMock() for name in window_names} gui._top_level = windows @@ -150,15 +150,12 @@ def test_client_show_and_raise_use_distinct_actions(method, action, window_names prop.return_value = launcher getattr(gui, method)() if not windows: - launcher._run_rpc.assert_called_once_with(action) + launcher._run_rpc.assert_called_once_with("raise") else: launcher._run_rpc.assert_not_called() for window in windows.values(): - if action == "show": - window._run_rpc.assert_called_once_with("show") - window.raise_window.assert_not_called() - else: - window.raise_window.assert_called_once_with() + window.raise_window.assert_called_once_with() + window._run_rpc.assert_not_called() def test_client_display_info_comes_from_server_without_starting_gui(): diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index 5c33365c..f89ca32e 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -111,9 +111,13 @@ class WindowEvents(QObject): @pytest.mark.parametrize("method", ["show", "raise"]) @pytest.mark.parametrize("stay_on_top", [False, True]) +@pytest.mark.parametrize("platform", ["wayland", "wayland-egl", "cocoa"]) def test_repeated_show_and_raise_do_not_hide_visible_window( - rpc_server, dummy_widget, qapp, method, stay_on_top + rpc_server, dummy_widget, qapp, monkeypatch, method, stay_on_top, platform ): + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) + # A background window remains visible. Exercise raising, even on an offscreen test backend. + monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) dummy_widget.setWindowFlag(Qt.WindowStaysOnTopHint, stay_on_top) dummy_widget.show() qapp.processEvents() @@ -128,7 +132,7 @@ def test_repeated_show_and_raise_do_not_hide_visible_window( assert events.hide_events == 0 -def test_show_visible_window_does_not_request_activation(rpc_server, dummy_widget): +def test_widget_show_visible_window_does_not_request_activation(rpc_server, dummy_widget): dummy_widget.show() with ( patch.object(dummy_widget, "raise_") as raise_window, @@ -152,7 +156,11 @@ def test_show_and_raise_restore_hidden_window(rpc_server, dummy_widget, method): @pytest.mark.parametrize("method", ["show", "raise"]) @pytest.mark.parametrize("state", [Qt.WindowNoState, Qt.WindowMaximized, Qt.WindowFullScreen]) -def test_show_and_raise_restore_minimized_window_state(rpc_server, dummy_widget, method, state): +@pytest.mark.parametrize("platform", ["wayland", "xcb"]) +def test_show_and_raise_restore_minimized_window_state( + rpc_server, dummy_widget, monkeypatch, method, state, platform +): + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) dummy_widget.setWindowState(state | Qt.WindowMinimized) dummy_widget.show() assert dummy_widget.isMinimized() @@ -176,6 +184,66 @@ def test_raise_requests_activation_after_showing_window(rpc_server, dummy_widget assert visible_during_activation == [True] +@pytest.mark.parametrize("platform", ["wayland", "xcb", "cocoa"]) +def test_raise_active_window_is_noop(rpc_server, dummy_widget, monkeypatch, platform): + dummy_widget.show() + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) + monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: True) + with ( + patch.object(dummy_widget, "setWindowFlags") as flags, + patch.object(dummy_widget, "setWindowState") as state, + patch.object(dummy_widget, "raise_") as raise_window, + patch.object(dummy_widget, "activateWindow") as activate, + patch.object(dummy_widget, "show") as show, + ): + rpc_server.run_rpc(dummy_widget, "raise", [], {}) + for operation in (flags, state, raise_window, activate, show): + operation.assert_not_called() + + +@pytest.mark.parametrize("platform", ["wayland", "wayland-egl", "cocoa"]) +def test_raise_visible_inactive_window_requests_activation( + rpc_server, dummy_widget, monkeypatch, platform +): + dummy_widget.show() + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) + monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) + with ( + patch.object(dummy_widget, "activateWindow") as activate, + patch.object(dummy_widget, "setWindowFlags") as flags, + ): + rpc_server.run_rpc(dummy_widget, "raise", [], {}) + activate.assert_called_once_with() + flags.assert_not_called() + + +@pytest.mark.parametrize("stay_on_top", [False, True]) +def test_xcb_raise_preserves_workaround_and_original_flags( + rpc_server, dummy_widget, monkeypatch, stay_on_top +): + # The actual QPA backend, not the desktop session or an environment override, selects the path. + monkeypatch.setenv("XDG_SESSION_TYPE", "wayland") + monkeypatch.setenv("QT_QPA_PLATFORM", "wayland") + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "xcb") + monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) + dummy_widget.setWindowFlag(Qt.WindowStaysOnTopHint, stay_on_top) + dummy_widget.show() + original_flags = dummy_widget.windowFlags() + + with ( + patch.object(dummy_widget, "setWindowFlags", wraps=dummy_widget.setWindowFlags) as flags, + patch.object(dummy_widget, "activateWindow") as activate, + ): + for _ in range(3): + rpc_server.run_rpc(dummy_widget, "raise", [], {}) + assert dummy_widget.isVisible() + assert dummy_widget.windowFlags() == original_flags + assert flags.call_count == 6 + assert flags.call_args_list[0].args == (original_flags | Qt.WindowStaysOnTopHint,) + assert flags.call_args_list[1].args == (original_flags,) + assert activate.call_count == 3 + + def test_display_info_is_available_through_system_rpc(rpc_server, qapp): info = rpc_server.run_system_rpc("system.get_display_info", [], {}) assert info["qt_platform"] == qapp.platformName()