mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-30 08:21:27 +01:00
fix(gui): restore foreground requests and scope raising workaround to xcb
This commit is contained in:
1 parent
869542a800
commit
341c52e380
5 files changed
+126
-39
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user