mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
fix(gui): recreate Wayland surfaces when raising windows
This commit is contained in:
1 parent
b6ed262f72
commit
f3453cb9b7
3 files changed
+137
-94
No files matched your search
@@ -106,12 +106,17 @@ when a window is behind another application. `gui.raise_window()` uses the same
|
||||
`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, the experimental workaround hides a visible, inactive
|
||||
window and schedules showing it on a subsequent Qt event-loop turn. Repeated requests share one
|
||||
pending show; an explicit RPC hide/show of that window, or a close, cancels it. Hidden windows are
|
||||
shown immediately.
|
||||
The workaround preserves contents and flags, but may flicker or let the compositor reposition the
|
||||
window. It does not guarantee foreground activation. macOS uses Qt's normal activation request.
|
||||
window flags afterwards. On native Wayland, the experimental workaround releases an inactive
|
||||
window's existing native resources before showing it. This avoids reusing the Wayland surface
|
||||
involved in alternating visibility with Qt 6.11.1 on the affected GNOME 40 console. It also applies to
|
||||
previously hidden windows. A window that has never been shown is shown normally.
|
||||
|
||||
The QWidget instances and their BEC connections stay alive; constructors and BEC cleanup are not
|
||||
called. Native QWindow handles and backing stores are recreated, so this has a rendering cost and
|
||||
cached native handles become invalid. Embedded renderers may have additional resource costs.
|
||||
Flags and the non-minimized window state are preserved, but the compositor may reposition the
|
||||
window and a visible window may flicker. Foreground activation remains compositor-dependent.
|
||||
macOS uses Qt's normal activation request.
|
||||
|
||||
Wayland's supported focus transfer uses an XDG activation token from the focused application.
|
||||
The terminal's input event/token is not carried by BEC's Redis RPC, so GNOME may reject activation.
|
||||
@@ -157,8 +162,8 @@ the output and the result of `gnome-shell --version` when reporting a GNOME issu
|
||||
If `cycle` alternates between appearing and disappearing, try `recreate` at least four times,
|
||||
one command at a time. It runs hide/destroy/show on the same QWidget: unlike `cycle`, it releases
|
||||
the native window resources before showing the widget again. This isolates native surface reuse
|
||||
without changing BEC's window behavior. It is a diagnostic experiment, not a validated workaround
|
||||
for BEC windows with docked widgets or embedded rendering contexts.
|
||||
without starting BEC. A successful plain-widget probe still needs verification with BEC's docked
|
||||
widgets and embedded renderers on the affected compositor.
|
||||
|
||||
To capture the Wayland protocol alongside the probe output, write to a unique temporary file if
|
||||
the checkout is not writable:
|
||||
|
||||
@@ -11,7 +11,7 @@ from bec_lib.client import BECClient
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_lib.utils.import_utils import lazy_import
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QTimer
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
@@ -40,24 +40,6 @@ class RegistryNotReadyError(Exception):
|
||||
"""Raised when trying to access an object from the RPC registry that is not yet registered."""
|
||||
|
||||
|
||||
class _WindowRaiser(QObject):
|
||||
"""Finish a Wayland hide/show across event-loop turns; the window owns the timer."""
|
||||
|
||||
def __init__(self, window: QWidget) -> None:
|
||||
super().__init__(window)
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setSingleShot(True)
|
||||
self.timer.timeout.connect(window.show)
|
||||
self.timer.timeout.connect(window.raise_)
|
||||
self.timer.timeout.connect(window.activateWindow)
|
||||
window.installEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if event.type() == QEvent.Close:
|
||||
self.timer.stop()
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def rpc_exception_hook(err_func):
|
||||
"""This context replaces the popup message box for error display with a specific hook"""
|
||||
@@ -256,11 +238,6 @@ class RPCServer:
|
||||
self._raise_window(obj.window())
|
||||
res = None
|
||||
else:
|
||||
if method in ("hide", "show") and isinstance(obj, QWidget) and obj.isWindow():
|
||||
# Explicit visibility requests supersede a deferred Wayland raise.
|
||||
raiser = obj.findChild(_WindowRaiser, options=Qt.FindDirectChildrenOnly)
|
||||
if raiser is not None:
|
||||
raiser.timer.stop()
|
||||
target_obj, method_obj = self._resolve_rpc_target(obj, method)
|
||||
# check if the method accepts args and kwargs
|
||||
if not callable(method_obj):
|
||||
@@ -279,10 +256,6 @@ class RPCServer:
|
||||
if window.isVisible() and window.isActiveWindow() and not window.isMinimized():
|
||||
return
|
||||
|
||||
raiser = window.findChild(_WindowRaiser, options=Qt.FindDirectChildrenOnly)
|
||||
if raiser is not None and raiser.timer.isActive():
|
||||
return
|
||||
|
||||
state = window.windowState() & ~Qt.WindowMinimized
|
||||
platform = QApplication.platformName()
|
||||
if platform == "xcb":
|
||||
@@ -300,16 +273,16 @@ class RPCServer:
|
||||
window.show()
|
||||
return
|
||||
|
||||
if window.isMinimized():
|
||||
window.setWindowState(state)
|
||||
if platform.startswith("wayland") and window.isVisible():
|
||||
# Give Qt an event-loop turn between hiding and showing. A synchronous hide/show
|
||||
# can leave the native Wayland surface hidden until the next RPC call.
|
||||
if raiser is None:
|
||||
raiser = _WindowRaiser(window)
|
||||
if platform.startswith("wayland") and window.windowHandle() is not None:
|
||||
# Qt 6.11/GNOME 40 can visually alternate when hide/show reuses a Wayland surface.
|
||||
# Release native resources, keeping the QWidget instances and their BEC connections.
|
||||
# Include previously hidden windows: plain show can reuse the affected surface too.
|
||||
screen = window.screen()
|
||||
window.hide()
|
||||
raiser.timer.start(0)
|
||||
return
|
||||
window.destroy()
|
||||
window.setScreen(screen)
|
||||
if window.windowState() != state:
|
||||
window.setWindowState(state)
|
||||
if not window.isVisible():
|
||||
window.show()
|
||||
window.raise_()
|
||||
|
||||
@@ -4,8 +4,9 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bec_lib.service_config import ServiceConfig
|
||||
from qtpy.QtCore import QCoreApplication, QEvent, QObject, Qt, QThread
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from qtpy.QtCore import QEvent, QObject, Qt, QThread
|
||||
from qtpy.QtTest import QSignalSpy
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.applications import companion_app as companion_app_module
|
||||
from bec_widgets.applications.companion_app import GUIServer
|
||||
@@ -130,10 +131,15 @@ def test_repeated_show_and_raise_preserve_visibility_and_flags(
|
||||
events = WindowEvents(dummy_widget)
|
||||
|
||||
for _ in range(3):
|
||||
handle = dummy_widget.windowHandle()
|
||||
rpc_server.run_rpc(dummy_widget, method, [], {})
|
||||
qapp.processEvents()
|
||||
assert dummy_widget.isVisible()
|
||||
assert dummy_widget.windowFlags() == flags
|
||||
if method == "raise" and platform.startswith("wayland"):
|
||||
assert dummy_widget.windowHandle() is not handle
|
||||
else:
|
||||
assert dummy_widget.windowHandle() is handle
|
||||
expected_remaps = 3 if method == "raise" and platform.startswith("wayland") else 0
|
||||
assert events.hide_events == events.show_events == expected_remaps
|
||||
|
||||
@@ -186,9 +192,10 @@ def test_raise_active_window_is_noop(rpc_server, dummy_widget, monkeypatch, plat
|
||||
patch.object(dummy_widget, "activateWindow") as activate,
|
||||
patch.object(dummy_widget, "show") as show,
|
||||
patch.object(dummy_widget, "hide") as hide,
|
||||
patch.object(dummy_widget, "destroy") as destroy,
|
||||
):
|
||||
rpc_server.run_rpc(dummy_widget, "raise", [], {})
|
||||
for operation in (flags, state, raise_window, activate, show, hide):
|
||||
for operation in (flags, state, raise_window, activate, show, hide, destroy):
|
||||
operation.assert_not_called()
|
||||
|
||||
|
||||
@@ -210,13 +217,22 @@ def test_raise_visible_inactive_window_requests_activation(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", ["wayland", "wayland-egl"])
|
||||
@pytest.mark.parametrize("initially_visible", [False, True])
|
||||
def test_wayland_raise_defers_remapping_but_shows_hidden_windows_immediately(
|
||||
rpc_server, dummy_widget, monkeypatch, qtbot, platform, initially_visible
|
||||
@pytest.mark.parametrize("initial_state", ["never_shown", "hidden", "visible"])
|
||||
def test_wayland_raise_preserves_widget_and_replaces_existing_native_window(
|
||||
rpc_server, dummy_widget, monkeypatch, qapp, platform, initial_state
|
||||
):
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform)
|
||||
monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False)
|
||||
dummy_widget.setVisible(initially_visible)
|
||||
if initial_state != "never_shown":
|
||||
dummy_widget.show()
|
||||
qapp.processEvents()
|
||||
if initial_state == "hidden":
|
||||
dummy_widget.hide()
|
||||
handle = dummy_widget.windowHandle()
|
||||
native_destroyed = QSignalSpy(handle.destroyed) if handle is not None else None
|
||||
widget_destroyed = QSignalSpy(dummy_widget.destroyed)
|
||||
last_window_closed = QSignalSpy(qapp.lastWindowClosed)
|
||||
screen = dummy_widget.screen()
|
||||
events = WindowEvents(dummy_widget)
|
||||
visible_during_activation = []
|
||||
|
||||
@@ -226,31 +242,17 @@ def test_wayland_raise_defers_remapping_but_shows_hidden_windows_immediately(
|
||||
side_effect=lambda: visible_during_activation.append(dummy_widget.isVisible()),
|
||||
):
|
||||
rpc_server.run_rpc(dummy_widget, "raise", [], {})
|
||||
if initially_visible:
|
||||
assert not dummy_widget.isVisible()
|
||||
assert visible_during_activation == []
|
||||
else:
|
||||
assert dummy_widget.isVisible()
|
||||
qtbot.waitUntil(lambda: bool(visible_during_activation))
|
||||
assert dummy_widget.isVisible()
|
||||
qapp.processEvents()
|
||||
|
||||
assert events.hide_events == int(initially_visible)
|
||||
assert events.hide_events == int(initial_state == "visible")
|
||||
assert events.show_events == 1
|
||||
assert visible_during_activation == [True]
|
||||
assert dummy_widget.isVisible()
|
||||
|
||||
|
||||
def test_repeated_wayland_raises_share_pending_remap(rpc_server, dummy_widget, monkeypatch, qtbot):
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland")
|
||||
monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False)
|
||||
dummy_widget.show()
|
||||
events = WindowEvents(dummy_widget)
|
||||
|
||||
for _ in range(3):
|
||||
rpc_server.run_rpc(dummy_widget, "raise", [], {})
|
||||
assert not dummy_widget.isVisible()
|
||||
|
||||
qtbot.waitUntil(dummy_widget.isVisible)
|
||||
assert events.hide_events == events.show_events == 1
|
||||
assert dummy_widget.windowHandle() is not handle
|
||||
assert dummy_widget.screen() is screen
|
||||
if native_destroyed is not None:
|
||||
assert native_destroyed.count() == 1
|
||||
assert widget_destroyed.count() == last_window_closed.count() == 0
|
||||
|
||||
|
||||
def test_wayland_raise_from_dispatcher_worker_runs_on_gui_thread(
|
||||
@@ -287,7 +289,7 @@ def test_wayland_raise_from_dispatcher_worker_runs_on_gui_thread(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["hide", "close", "show"])
|
||||
def test_visibility_request_cancels_pending_wayland_raise(
|
||||
def test_visibility_request_after_wayland_raise_is_not_undone(
|
||||
rpc_server, dummy_widget, monkeypatch, qapp, method
|
||||
):
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland")
|
||||
@@ -303,26 +305,11 @@ def test_visibility_request_cancels_pending_wayland_raise(
|
||||
qapp.processEvents()
|
||||
|
||||
assert dummy_widget.isVisible() == (method == "show")
|
||||
activate.assert_not_called()
|
||||
|
||||
|
||||
def test_destroying_window_cancels_pending_wayland_raise(rpc_server, qapp, monkeypatch):
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland")
|
||||
window = QWidget() # Deleted explicitly below, before the timer can fire.
|
||||
monkeypatch.setattr(window, "isActiveWindow", lambda: False)
|
||||
window.show()
|
||||
|
||||
with patch.object(window, "activateWindow") as activate:
|
||||
rpc_server.run_rpc(window, "raise", [], {})
|
||||
window.deleteLater()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
qapp.processEvents()
|
||||
|
||||
activate.assert_not_called()
|
||||
activate.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["hide", "show"])
|
||||
def test_child_visibility_does_not_cancel_pending_window_raise(
|
||||
def test_child_visibility_after_wayland_raise_does_not_hide_window(
|
||||
rpc_server, dummy_widget, qtbot, qapp, monkeypatch, method
|
||||
):
|
||||
window = QWidget()
|
||||
@@ -341,14 +328,22 @@ def test_child_visibility_does_not_cancel_pending_window_raise(
|
||||
assert dummy_widget.isVisible() == (method == "show")
|
||||
|
||||
|
||||
def test_wayland_remap_preserves_dock_contents(rpc_server, qtbot, qapp, monkeypatch, tmp_path):
|
||||
def test_wayland_recreation_preserves_dock_data_and_bec_connections(
|
||||
rpc_server, qtbot, qapp, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path))
|
||||
dock_area = rpc_server._launch_dock_area(name="flomni", startup_profile="skip")
|
||||
window = dock_area.window()
|
||||
qtbot.addWidget(window)
|
||||
content = dock_area.new("TextBox", object_name="status")
|
||||
content.set_plain_text("Preserved status")
|
||||
waveform = dock_area.new("Waveform", object_name="waveform")
|
||||
curve = waveform.plot(x=[1, 2, 3], y=[4, 5, 6])
|
||||
qapp.processEvents()
|
||||
widgets = dock_area.widget_map()
|
||||
slots = dict(dock_area.bec_dispatcher._registered_slots)
|
||||
objects = [window, dock_area, content, waveform, curve]
|
||||
destroyed = [QSignalSpy(obj.destroyed) for obj in objects]
|
||||
size = window.size()
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland")
|
||||
monkeypatch.setattr(window, "isActiveWindow", lambda: False)
|
||||
@@ -359,8 +354,78 @@ def test_wayland_remap_preserves_dock_contents(rpc_server, qtbot, qapp, monkeypa
|
||||
assert window.isVisible()
|
||||
assert window.size() == size
|
||||
assert content.isVisible()
|
||||
assert content.plain_text == "Preserved status"
|
||||
x, y = curve.get_data()
|
||||
assert list(x) == [1, 2, 3]
|
||||
assert list(y) == [4, 5, 6]
|
||||
assert dock_area.widget_map() == widgets
|
||||
assert rpc_server.rpc_register.get_rpc_by_id(dock_area.gui_id) is dock_area
|
||||
assert dock_area.bec_dispatcher._registered_slots == slots
|
||||
for obj in objects[1:]:
|
||||
assert rpc_server.rpc_register.get_rpc_by_id(obj.gui_id) is obj
|
||||
assert all(spy.count() == 0 for spy in destroyed)
|
||||
|
||||
# The same widgets can still receive RPC updates after recreating their native resources.
|
||||
rpc_server.run_rpc(content, "set_plain_text", ["Updated status"], {})
|
||||
assert content.plain_text == "Updated status"
|
||||
curve.set_data([7, 8], [9, 10])
|
||||
x, y = curve.get_data()
|
||||
assert list(x) == [7, 8]
|
||||
assert list(y) == [9, 10]
|
||||
|
||||
|
||||
def test_wayland_recreation_preserves_native_child_widget(rpc_server, qtbot, monkeypatch, qapp):
|
||||
window = QWidget()
|
||||
qtbot.addWidget(window)
|
||||
child = QWidget(window)
|
||||
child.setAttribute(Qt.WA_NativeWindow)
|
||||
QVBoxLayout(window).addWidget(child)
|
||||
window.show()
|
||||
qapp.processEvents()
|
||||
native_visible = child.windowHandle().isVisible()
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland")
|
||||
monkeypatch.setattr(window, "isActiveWindow", lambda: False)
|
||||
destroyed = QSignalSpy(child.destroyed)
|
||||
|
||||
for _ in range(3):
|
||||
rpc_server.run_rpc(window, "raise", [], {})
|
||||
qapp.processEvents()
|
||||
assert child.parentWidget() is window
|
||||
assert child.isVisible()
|
||||
# Some QPA plugins (including offscreen) do not map native child windows.
|
||||
assert child.windowHandle().isVisible() == native_visible
|
||||
assert child.windowHandle().parent() is window.windowHandle()
|
||||
assert destroyed.count() == 0
|
||||
|
||||
|
||||
def test_wayland_recreation_preserves_web_page_state(
|
||||
rpc_server, qtbot, monkeypatch, qapp, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path))
|
||||
dock_area = rpc_server._launch_dock_area(name="web_probe", startup_profile="skip")
|
||||
window = dock_area.window()
|
||||
qtbot.addWidget(window)
|
||||
content = dock_area.new("WebsiteWidget", object_name="web_page")
|
||||
view = content.website
|
||||
page = view.page()
|
||||
with qtbot.waitSignal(page.loadFinished, timeout=10000):
|
||||
view.setHtml(
|
||||
"<html><body>Preserved page<script>window.probeCounter = 40;</script></body></html>"
|
||||
)
|
||||
reloads = QSignalSpy(page.loadStarted)
|
||||
destroyed = QSignalSpy(page.destroyed)
|
||||
monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland")
|
||||
monkeypatch.setattr(window, "isActiveWindow", lambda: False)
|
||||
|
||||
for expected in (41, 42, 43):
|
||||
rpc_server.run_rpc(window, "raise", [], {})
|
||||
qapp.processEvents()
|
||||
assert content.website is view
|
||||
assert view.page() is page
|
||||
result = []
|
||||
page.runJavaScript("window.probeCounter += 1", result.append)
|
||||
qtbot.waitUntil(lambda: bool(result))
|
||||
assert result == [expected]
|
||||
assert reloads.count() == destroyed.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stay_on_top", [False, True])
|
||||
|
||||
Reference in new issue
Block a user