fix(gui): preserve visibility when showing and raising windows

This commit is contained in:
wyzula_j committed 2026-09-10 13:45:16 +02:00
1 parent 351d98bfd8
commit 869542a800
8 files changed
+375 -30

No files matched your search

+31
View File
@@ -99,6 +99,37 @@ active devices, topics)—so your UI can be heavily automated.
![rpc_opti](https://github.com/user-attachments/assets/666be7fb-9a0d-44c2-8d44-2f9d1dae4497)
#### 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
+18 -3
View File
@@ -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):
+94
View File
@@ -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"])),
}
+20 -27
View File
@@ -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
+26
View File
@@ -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
+50
View File
@@ -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()
+50
View File
@@ -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
+86
View File
@@ -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