mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-11-03 09:05:53 +01:00
fix(gui): preserve visibility when showing and raising windows
This commit is contained in:
1 parent
351d98bfd8
commit
869542a800
8 files changed
+375
-30
No files matched your search
@@ -99,6 +99,37 @@ 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.
|
||||
|
||||
To inspect the **running GUI server process**, from the BEC IPython client:
|
||||
|
||||
```python
|
||||
from pprint import pprint
|
||||
|
||||
pprint(gui.get_display_info())
|
||||
```
|
||||
|
||||
The report includes the loaded Qt platform (`wayland`, `xcb` for X11/XWayland, or `cocoa` on macOS),
|
||||
Qt version and plugin search paths, Python executable, selected inherited environment variables,
|
||||
screens, and window visibility/focus state. `XDG_CURRENT_DESKTOP` and `DESKTOP_SESSION` are desktop
|
||||
hints, not authoritative compositor identification. The session can be Wayland while Qt uses `xcb`.
|
||||
Qt settings or plugin paths inherited from a shell or another application's launcher appear in the
|
||||
environment section. Diagnostics do not start the GUI or change its settings.
|
||||
|
||||
After updating this code, restart both the BEC client and its GUI server before testing. On the
|
||||
affected console, compare the report before and after repeated `gui.show()` and `gui.raise_window()`
|
||||
calls. `visible=True` with `active=False` means Qt considers the window shown but without keyboard
|
||||
focus; it does not prove the window is unobscured or on the current workspace.
|
||||
|
||||
For an XWayland comparison on a Linux desktop with XWayland available, start a fresh session with
|
||||
`QT_QPA_PLATFORM=xcb bec --session flomni` and verify that `qt_platform` is `xcb`. This sets the backend
|
||||
for that invocation only. For plugin-loading details, start with `QT_DEBUG_PLUGINS=1 bec --session flomni`.
|
||||
|
||||
### 4. Rapid development (extensible by design)
|
||||
|
||||
Build new widgets fast: Inherit from `BECWidget`, list your RPC methods in `USER_ACCESS`, and use `bec_dispatcher` to
|
||||
|
||||
@@ -416,7 +416,8 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def show(self, wait=True) -> None:
|
||||
"""
|
||||
Show the GUI window.
|
||||
Show GUI windows without explicitly requesting focus.
|
||||
Already-visible windows are left unchanged; minimized windows are restored.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
@@ -432,7 +433,8 @@ class BECGuiClient(RPCBase):
|
||||
|
||||
def raise_window(self, wait: bool = True) -> None:
|
||||
"""
|
||||
Bring GUI windows to the front.
|
||||
Show GUI windows and request that the desktop brings them to the front.
|
||||
The window manager/compositor may reject the focus request.
|
||||
If the GUI server is not running, it will be started.
|
||||
|
||||
Args:
|
||||
@@ -442,6 +444,19 @@ class BECGuiClient(RPCBase):
|
||||
return self._raise_all()
|
||||
return self._start(wait=wait)
|
||||
|
||||
def get_display_info(self) -> dict:
|
||||
"""Return display diagnostics from the running GUI server process.
|
||||
|
||||
Includes the actual Qt platform plugin, Qt paths, selected environment variables,
|
||||
and window visibility/focus state. Desktop environment variables are hints, not
|
||||
authoritative identification of a Wayland compositor. Does not start the GUI.
|
||||
|
||||
Returns:
|
||||
dict: GUI server display diagnostics, suitable for ``pprint`` or JSON export.
|
||||
"""
|
||||
with wait_for_server(self):
|
||||
return self.launcher._run_rpc("system.get_display_info")
|
||||
|
||||
def change_theme(self, theme: Literal["light", "dark"] | None = None) -> None:
|
||||
"""
|
||||
Apply a GUI theme or toggle between dark and light.
|
||||
@@ -730,7 +745,7 @@ class BECGuiClient(RPCBase):
|
||||
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.raise_window()
|
||||
window._run_rpc("show")
|
||||
|
||||
def _show_all(self):
|
||||
with wait_for_server(self):
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Read-only Qt display diagnostics collected inside the GUI process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from importlib.metadata import version
|
||||
|
||||
from qtpy import API_NAME
|
||||
from qtpy.QtCore import qVersion
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
import bec_widgets
|
||||
|
||||
DISPLAY_ENVIRONMENT_VARIABLES = (
|
||||
"XDG_SESSION_TYPE",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
"XDG_SESSION_DESKTOP",
|
||||
"DESKTOP_SESSION",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"QT_API",
|
||||
"QT_QPA_PLATFORM",
|
||||
"QT_QPA_PLATFORM_PLUGIN_PATH",
|
||||
"QT_PLUGIN_PATH",
|
||||
"QT_QPA_PLATFORMTHEME",
|
||||
"QT_QPA_GENERIC_PLUGINS",
|
||||
"QT_STYLE_OVERRIDE",
|
||||
"QT_WAYLAND_SHELL_INTEGRATION",
|
||||
"QT_WAYLAND_DISABLE_WINDOWDECORATION",
|
||||
"LD_LIBRARY_PATH",
|
||||
"LD_PRELOAD",
|
||||
)
|
||||
|
||||
|
||||
def get_display_info() -> dict:
|
||||
"""Return the GUI process's loaded Qt backend, environment hints, and window states.
|
||||
|
||||
Must run in the Qt GUI thread. Environment variables describe the inherited session;
|
||||
``qt_platform`` identifies the platform plugin actually loaded by this application.
|
||||
Qt has no portable API for identifying the Wayland compositor.
|
||||
|
||||
Returns:
|
||||
dict: JSON-serializable diagnostics without modifying any window or environment setting.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
raise RuntimeError("Display diagnostics require a running QApplication.")
|
||||
|
||||
windows = []
|
||||
for widget in app.topLevelWidgets():
|
||||
handle = widget.windowHandle()
|
||||
# Ignore internal widgets without a native window; do not create one to inspect it.
|
||||
if handle is None:
|
||||
continue
|
||||
windows.append(
|
||||
{
|
||||
"object_name": widget.objectName(),
|
||||
"title": widget.windowTitle(),
|
||||
"visible": widget.isVisible(),
|
||||
"minimized": widget.isMinimized(),
|
||||
"maximized": widget.isMaximized(),
|
||||
"full_screen": widget.isFullScreen(),
|
||||
"active": widget.isActiveWindow(),
|
||||
"exposed": handle.isExposed(),
|
||||
"flags": str(widget.windowFlags()),
|
||||
"geometry": list(widget.geometry().getRect()),
|
||||
"screen": handle.screen().name() if handle.screen() is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"pid": os.getpid(),
|
||||
"os": sys.platform,
|
||||
"python_executable": sys.executable,
|
||||
"bec_widgets_version": version("bec_widgets"),
|
||||
"bec_widgets_path": bec_widgets.__file__,
|
||||
"qt_version": qVersion(),
|
||||
"qt_binding": API_NAME,
|
||||
"qt_platform": app.platformName(),
|
||||
"qt_library_paths": app.libraryPaths(),
|
||||
"qt_style": app.style().objectName(),
|
||||
"environment": {name: os.environ.get(name) for name in DISPLAY_ENVIRONMENT_VARIABLES},
|
||||
"screens": [
|
||||
{
|
||||
"name": screen.name(),
|
||||
"geometry": list(screen.geometry().getRect()),
|
||||
"available_geometry": list(screen.availableGeometry().getRect()),
|
||||
"device_pixel_ratio": screen.devicePixelRatio(),
|
||||
}
|
||||
for screen in app.screens()
|
||||
],
|
||||
"windows": sorted(windows, key=lambda window: (window["object_name"], window["title"])),
|
||||
}
|
||||
@@ -18,6 +18,7 @@ from redis.exceptions import RedisError
|
||||
from bec_widgets.utils.bec_connector import BECConnector
|
||||
from bec_widgets.utils.bec_dispatcher import BECDispatcher
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.display_info import get_display_info
|
||||
from bec_widgets.utils.error_popups import ErrorPopupUtility
|
||||
from bec_widgets.utils.rpc_logging import elapsed_seconds, format_elapsed
|
||||
from bec_widgets.utils.rpc_register import RPCRegister
|
||||
@@ -234,32 +235,18 @@ 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 == "raise" and hasattr(
|
||||
obj, "setWindowState"
|
||||
): # special case for raising windows, should work even if minimized
|
||||
# this is a special case for raising windows for gnome on Red Hat (RHEL) 9 systems where changing focus is suppressed by default
|
||||
# The procedure is as follows:
|
||||
# 1. Get the current window state to check if the window is minimized and remove minimized flag
|
||||
# 2. Then in order to force gnome to raise the window, we set the window to stay on top temporarily
|
||||
# and call raise_() and activateWindow()
|
||||
# This forces gnome to raise the window even if focus stealing is prevented
|
||||
# 3. Flag for stay on top is removed again to restore the original window state
|
||||
# 4. Finally, we call show() to ensure the window is visible
|
||||
|
||||
state = getattr(obj, "windowState", lambda: Qt.WindowNoState)()
|
||||
target_state = state | Qt.WindowActive
|
||||
if state & Qt.WindowMinimized:
|
||||
target_state &= ~Qt.WindowMinimized
|
||||
obj.setWindowState(target_state)
|
||||
if hasattr(obj, "showNormal") and state & Qt.WindowMinimized:
|
||||
obj.showNormal()
|
||||
if hasattr(obj, "raise_"):
|
||||
obj.setWindowFlags(obj.windowFlags() | Qt.WindowStaysOnTopHint)
|
||||
obj.raise_()
|
||||
if hasattr(obj, "activateWindow"):
|
||||
obj.activateWindow()
|
||||
obj.setWindowFlags(obj.windowFlags() & ~Qt.WindowStaysOnTopHint)
|
||||
obj.show()
|
||||
if method in ("show", "raise") and isinstance(obj, QWidget):
|
||||
window = obj.window() if method == "raise" else obj
|
||||
if window.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()
|
||||
res = None
|
||||
else:
|
||||
target_obj, method_obj = self._resolve_rpc_target(obj, method)
|
||||
@@ -319,8 +306,14 @@ class RPCServer:
|
||||
return self._launch_dock_area(*args, **kwargs)
|
||||
if method == "system.shutdown":
|
||||
return self._shutdown_gui_server()
|
||||
if method == "system.get_display_info":
|
||||
return get_display_info()
|
||||
if method == "system.list_capabilities":
|
||||
return {"system.launch_dock_area": True, "system.shutdown": True}
|
||||
return {
|
||||
"system.launch_dock_area": True,
|
||||
"system.shutdown": True,
|
||||
"system.get_display_info": True,
|
||||
}
|
||||
raise ValueError(f"Unknown system RPC method: {method}")
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -23,3 +23,29 @@ def test_ipython_tab_completion(bec_ipython_shell):
|
||||
assert "gui.bec" in completer.all_completions("gui.")
|
||||
assert "gui.bec.new" in completer.all_completions("gui.bec.")
|
||||
assert "gui.bec.widget_list" in completer.all_completions("gui.bec.widget_")
|
||||
|
||||
|
||||
def test_gui_visibility_and_display_info(connected_client_gui_obj):
|
||||
gui = connected_client_gui_obj
|
||||
flomni = gui.new("flomni", startup_profile="skip")
|
||||
|
||||
info = gui.get_display_info()
|
||||
assert info["pid"] == gui._process.pid
|
||||
assert info["qt_platform"]
|
||||
|
||||
def flomni_state():
|
||||
return next(
|
||||
window
|
||||
for window in gui.get_display_info()["windows"]
|
||||
if window["title"] == "BEC - flomni"
|
||||
)
|
||||
|
||||
flags = flomni_state()["flags"]
|
||||
for show_window in (gui.show, gui.raise_window, flomni.raise_window):
|
||||
flomni.hide()
|
||||
assert not flomni_state()["visible"]
|
||||
for _ in range(3):
|
||||
show_window()
|
||||
state = flomni_state()
|
||||
assert state["visible"]
|
||||
assert state["flags"] == flags
|
||||
@@ -135,6 +135,56 @@ def _no_wait_for_server(_client):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method, action", [("show", "show"), ("raise_window", "raise")])
|
||||
@pytest.mark.parametrize("window_names", [(), ("bec", "flomni")])
|
||||
def test_client_show_and_raise_use_distinct_actions(method, action, window_names):
|
||||
gui = BECGuiClient()
|
||||
windows = {name: mock.MagicMock() for name in window_names}
|
||||
gui._top_level = windows
|
||||
launcher = mock.MagicMock()
|
||||
with (
|
||||
mock.patch.object(BECGuiClient, "launcher", new_callable=mock.PropertyMock) as prop,
|
||||
mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server),
|
||||
mock.patch.object(gui, "_check_if_server_is_alive", return_value=True),
|
||||
):
|
||||
prop.return_value = launcher
|
||||
getattr(gui, method)()
|
||||
if not windows:
|
||||
launcher._run_rpc.assert_called_once_with(action)
|
||||
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()
|
||||
|
||||
|
||||
def test_client_display_info_comes_from_server_without_starting_gui():
|
||||
gui = BECGuiClient()
|
||||
with (
|
||||
mock.patch.object(BECGuiClient, "launcher", new_callable=mock.PropertyMock) as prop,
|
||||
mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server),
|
||||
mock.patch.object(gui, "_start") as start,
|
||||
):
|
||||
prop.return_value._run_rpc.return_value = {"qt_platform": "wayland"}
|
||||
assert gui.get_display_info() == {"qt_platform": "wayland"}
|
||||
prop.return_value._run_rpc.assert_called_once_with("system.get_display_info")
|
||||
start.assert_not_called()
|
||||
|
||||
|
||||
def test_client_display_info_fails_without_running_server():
|
||||
gui = BECGuiClient()
|
||||
with (
|
||||
mock.patch.object(gui, "_gui_is_alive", return_value=False),
|
||||
mock.patch.object(gui, "_start") as start,
|
||||
pytest.raises(RuntimeError, match="GUI is not alive"),
|
||||
):
|
||||
gui.get_display_info()
|
||||
start.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme", ["light", "dark"])
|
||||
def test_client_utils_apply_theme_explicit(theme):
|
||||
gui = BECGuiClient()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Tests for diagnostics collected from the GUI process."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.display_info import get_display_info
|
||||
|
||||
|
||||
def test_display_info_uses_loaded_backend_not_environment(qapp, monkeypatch):
|
||||
monkeypatch.setenv("QT_QPA_PLATFORM", "different-from-loaded-backend")
|
||||
monkeypatch.setenv("XDG_CURRENT_DESKTOP", "GNOME")
|
||||
monkeypatch.setenv("QT_PLUGIN_PATH", "/another/qt/plugins")
|
||||
monkeypatch.setenv("UNRELATED_SECRET", "not-for-diagnostics")
|
||||
|
||||
info = get_display_info()
|
||||
|
||||
assert info["qt_platform"] == qapp.platformName()
|
||||
assert info["pid"] == os.getpid()
|
||||
assert info["python_executable"] == sys.executable
|
||||
assert info["qt_library_paths"] == qapp.libraryPaths()
|
||||
assert info["environment"]["QT_QPA_PLATFORM"] == "different-from-loaded-backend"
|
||||
assert info["environment"]["XDG_CURRENT_DESKTOP"] == "GNOME"
|
||||
assert info["environment"]["QT_PLUGIN_PATH"] == "/another/qt/plugins"
|
||||
assert "UNRELATED_SECRET" not in info["environment"]
|
||||
json.dumps(info)
|
||||
|
||||
|
||||
def test_display_info_reports_visibility_without_changing_windows(qtbot, qapp):
|
||||
window = QWidget()
|
||||
qtbot.addWidget(window)
|
||||
window.setObjectName("display_info_test")
|
||||
window.show()
|
||||
qapp.processEvents()
|
||||
|
||||
for visible in (True, False):
|
||||
window.setVisible(visible)
|
||||
info = get_display_info()
|
||||
state = next(item for item in info["windows"] if item["object_name"] == window.objectName())
|
||||
assert state["visible"] is visible
|
||||
assert state["minimized"] is False
|
||||
assert window.isVisible() is visible
|
||||
|
||||
unshown = QWidget()
|
||||
qtbot.addWidget(unshown)
|
||||
assert unshown.windowHandle() is None
|
||||
get_display_info()
|
||||
assert unshown.windowHandle() is None
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QEvent, QObject, Qt
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.applications import companion_app as companion_app_module
|
||||
@@ -92,9 +93,94 @@ def test_rpc_server_system_capabilities_include_shutdown(rpc_server):
|
||||
assert rpc_server.run_system_rpc("system.list_capabilities", [], {}) == {
|
||||
"system.launch_dock_area": True,
|
||||
"system.shutdown": True,
|
||||
"system.get_display_info": True,
|
||||
}
|
||||
|
||||
|
||||
class WindowEvents(QObject):
|
||||
def __init__(self, widget):
|
||||
super().__init__(widget)
|
||||
self.hide_events = 0
|
||||
widget.installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if event.type() == QEvent.Hide:
|
||||
self.hide_events += 1
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["show", "raise"])
|
||||
@pytest.mark.parametrize("stay_on_top", [False, True])
|
||||
def test_repeated_show_and_raise_do_not_hide_visible_window(
|
||||
rpc_server, dummy_widget, qapp, method, stay_on_top
|
||||
):
|
||||
dummy_widget.setWindowFlag(Qt.WindowStaysOnTopHint, stay_on_top)
|
||||
dummy_widget.show()
|
||||
qapp.processEvents()
|
||||
flags = dummy_widget.windowFlags()
|
||||
events = WindowEvents(dummy_widget)
|
||||
|
||||
for _ in range(3):
|
||||
rpc_server.run_rpc(dummy_widget, method, [], {})
|
||||
qapp.processEvents()
|
||||
assert dummy_widget.isVisible()
|
||||
assert dummy_widget.windowFlags() == flags
|
||||
assert events.hide_events == 0
|
||||
|
||||
|
||||
def test_show_visible_window_does_not_request_activation(rpc_server, dummy_widget):
|
||||
dummy_widget.show()
|
||||
with (
|
||||
patch.object(dummy_widget, "raise_") as raise_window,
|
||||
patch.object(dummy_widget, "activateWindow") as activate_window,
|
||||
patch.object(dummy_widget, "show") as show_window,
|
||||
patch.object(dummy_widget, "setWindowState") as set_state,
|
||||
):
|
||||
rpc_server.run_rpc(dummy_widget, "show", [], {})
|
||||
raise_window.assert_not_called()
|
||||
activate_window.assert_not_called()
|
||||
show_window.assert_not_called()
|
||||
set_state.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["show", "raise"])
|
||||
def test_show_and_raise_restore_hidden_window(rpc_server, dummy_widget, method):
|
||||
dummy_widget.hide()
|
||||
rpc_server.run_rpc(dummy_widget, method, [], {})
|
||||
assert dummy_widget.isVisible()
|
||||
|
||||
|
||||
@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):
|
||||
dummy_widget.setWindowState(state | Qt.WindowMinimized)
|
||||
dummy_widget.show()
|
||||
assert dummy_widget.isMinimized()
|
||||
|
||||
rpc_server.run_rpc(dummy_widget, method, [], {})
|
||||
|
||||
assert dummy_widget.isVisible()
|
||||
assert not dummy_widget.isMinimized()
|
||||
assert dummy_widget.windowState() & (Qt.WindowMaximized | Qt.WindowFullScreen) == state
|
||||
|
||||
|
||||
def test_raise_requests_activation_after_showing_window(rpc_server, dummy_widget):
|
||||
dummy_widget.hide()
|
||||
visible_during_activation = []
|
||||
with patch.object(
|
||||
dummy_widget,
|
||||
"activateWindow",
|
||||
side_effect=lambda: visible_during_activation.append(dummy_widget.isVisible()),
|
||||
):
|
||||
rpc_server.run_rpc(dummy_widget, "raise", [], {})
|
||||
assert visible_during_activation == [True]
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def test_rpc_server_system_shutdown_requests_gui_server_shutdown(rpc_server, qapp):
|
||||
gui_server = MagicMock()
|
||||
qapp.gui_server = gui_server
|
||||
|
||||
Reference in new issue
Block a user