diff --git a/README.md b/README.md index 536ac911..a9b8f766 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 0dbf8fc9..8307fed6 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -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): diff --git a/bec_widgets/utils/display_info.py b/bec_widgets/utils/display_info.py new file mode 100644 index 00000000..c37829e0 --- /dev/null +++ b/bec_widgets/utils/display_info.py @@ -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"])), + } diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index eff7798d..d0dd26e3 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -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 diff --git a/tests/end-2-end/test_bec_gui_ipython.py b/tests/end-2-end/test_bec_gui_ipython.py index 84493af7..ca8dcdb2 100644 --- a/tests/end-2-end/test_bec_gui_ipython.py +++ b/tests/end-2-end/test_bec_gui_ipython.py @@ -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 diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index 23200a25..21ee7fb0 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -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() diff --git a/tests/unit_tests/test_display_info.py b/tests/unit_tests/test_display_info.py new file mode 100644 index 00000000..76235195 --- /dev/null +++ b/tests/unit_tests/test_display_info.py @@ -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 diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index 27b947a9..5c33365c 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -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