fix(gui): recreate Wayland surfaces when raising windows

This commit is contained in:
wyzula_j committed 2026-09-10 15:34:05 +02:00
1 parent b6ed262f72
commit f3453cb9b7
3 files changed
+137 -94

No files matched your search

+114 -49
View File
@@ -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])