diff --git a/README.md b/README.md index d71735d8..3c72105a 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,12 @@ 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 and macOS it requests activation without changing flags. -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. +window flags afterwards. On native Wayland, BEC briefly hides and shows visible, inactive windows +before requesting activation, because GNOME can refuse to activate an existing window. This keeps +the window's contents and flags, but may cause flicker or let the compositor reposition it. Each +call finishes with the window shown; already-active windows are left untouched. On macOS, BEC +requests activation without remapping the window. Activation is still subject to the window +manager/compositor's focus policy; a Redis RPC is not a local mouse or keyboard event. To inspect the **running GUI server process**, from the BEC IPython client: diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index ee47e303..7f6607db 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -265,7 +265,8 @@ class RPCServer: return state = window.windowState() & ~Qt.WindowMinimized - if QApplication.platformName() == "xcb": + platform = QApplication.platformName() + if platform == "xcb": # Keep the GNOME/RHEL X11 workaround that also works through XWayland. # Never run it on native Wayland: flag changes hide/recreate the surface. flags = window.windowFlags() @@ -282,10 +283,12 @@ class RPCServer: if window.isMinimized(): window.setWindowState(state) + if platform.startswith("wayland") and window.isVisible(): + # GNOME Wayland can refuse activation of an existing surface. Remap the + # inactive window using hide/show, which brings it forward on those consoles. + window.hide() if not window.isVisible(): window.show() - # Qt requests activation from the compositor on Wayland. A visible window can - # still be behind another application, so visibility alone must not skip this. window.raise_() window.activateWindow() diff --git a/tests/unit_tests/test_rpc_server.py b/tests/unit_tests/test_rpc_server.py index f89ca32e..4e386d20 100644 --- a/tests/unit_tests/test_rpc_server.py +++ b/tests/unit_tests/test_rpc_server.py @@ -101,18 +101,21 @@ class WindowEvents(QObject): def __init__(self, widget): super().__init__(widget) self.hide_events = 0 + self.show_events = 0 widget.installEventFilter(self) def eventFilter(self, watched, event): if event.type() == QEvent.Hide: self.hide_events += 1 + elif event.type() == QEvent.Show: + self.show_events += 1 return False @pytest.mark.parametrize("method", ["show", "raise"]) @pytest.mark.parametrize("stay_on_top", [False, True]) @pytest.mark.parametrize("platform", ["wayland", "wayland-egl", "cocoa"]) -def test_repeated_show_and_raise_do_not_hide_visible_window( +def test_repeated_show_and_raise_preserve_visibility_and_flags( rpc_server, dummy_widget, qapp, monkeypatch, method, stay_on_top, platform ): monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) @@ -129,7 +132,8 @@ def test_repeated_show_and_raise_do_not_hide_visible_window( qapp.processEvents() assert dummy_widget.isVisible() assert dummy_widget.windowFlags() == flags - assert events.hide_events == 0 + expected_remaps = 3 if method == "raise" and platform.startswith("wayland") else 0 + assert events.hide_events == events.show_events == expected_remaps def test_widget_show_visible_window_does_not_request_activation(rpc_server, dummy_widget): @@ -195,9 +199,10 @@ def test_raise_active_window_is_noop(rpc_server, dummy_widget, monkeypatch, plat patch.object(dummy_widget, "raise_") as raise_window, patch.object(dummy_widget, "activateWindow") as activate, patch.object(dummy_widget, "show") as show, + patch.object(dummy_widget, "hide") as hide, ): rpc_server.run_rpc(dummy_widget, "raise", [], {}) - for operation in (flags, state, raise_window, activate, show): + for operation in (flags, state, raise_window, activate, show, hide): operation.assert_not_called() @@ -217,6 +222,52 @@ def test_raise_visible_inactive_window_requests_activation( flags.assert_not_called() +@pytest.mark.parametrize("platform", ["wayland", "wayland-egl"]) +@pytest.mark.parametrize("initially_visible", [False, True]) +def test_wayland_raise_finishes_showing_before_requesting_activation( + rpc_server, dummy_widget, monkeypatch, platform, initially_visible +): + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: platform) + monkeypatch.setattr(dummy_widget, "isActiveWindow", lambda: False) + dummy_widget.setVisible(initially_visible) + events = WindowEvents(dummy_widget) + 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 events.hide_events == int(initially_visible) + assert events.show_events == 1 + assert visible_during_activation == [True] + assert dummy_widget.isVisible() + + +def test_wayland_remap_preserves_dock_contents(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") + qapp.processEvents() + widgets = dock_area.widget_map() + size = window.size() + monkeypatch.setattr(rpc_server_module.QApplication, "platformName", lambda: "wayland") + monkeypatch.setattr(window, "isActiveWindow", lambda: False) + + for _ in range(3): + rpc_server.run_rpc(window, "raise", [], {}) + qapp.processEvents() + assert window.isVisible() + assert window.size() == size + assert content.isVisible() + assert dock_area.widget_map() == widgets + assert rpc_server.rpc_register.get_rpc_by_id(dock_area.gui_id) is dock_area + + @pytest.mark.parametrize("stay_on_top", [False, True]) def test_xcb_raise_preserves_workaround_and_original_flags( rpc_server, dummy_widget, monkeypatch, stay_on_top