fix(gui): restore foreground requests and scope raising workaround to xcb

This commit is contained in:
wyzula_j committed 2026-09-10 14:15:46 +02:00
1 parent 869542a800
commit 341c52e380
5 files changed
+126 -39

No files matched your search

+8 -4
View File
@@ -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:
+3 -13
View File
@@ -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:
+39 -11
View File
@@ -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.
+5 -8
View File
@@ -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():
+71 -3
View File
@@ -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()