build: support PySide6 6.11.1, pyqtgraph 0.14 and PySide6-QtAds 5.0.0, remove force x11 on Linux

This commit is contained in:
2026-07-15 14:57:57 +02:00
parent f558a1cdd4
commit 052cb43ba4
16 changed files with 198 additions and 112 deletions
-8
View File
@@ -1,13 +1,5 @@
import os
import sys
import bec_widgets.widgets.containers.qt_ads as QtAds
if sys.platform.startswith("linux"):
qt_platform = os.environ.get("QT_QPA_PLATFORM", "")
if qt_platform != "offscreen":
os.environ["QT_QPA_PLATFORM"] = "xcb"
# Default QtAds configuration
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
+1 -1
View File
@@ -408,7 +408,7 @@ class BECWidget(BECConnector):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
if not self._destroyed:
self.cleanup()
self._destroyed = True
self.cleanup()
finally:
super().closeEvent(event) # pylint: disable=no-member
+13 -5
View File
@@ -529,10 +529,10 @@ class Crosshair(QObject):
if event is None:
return # nothing to do
scene_pos = event[0] # SignalProxy bundle
if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos):
return
view_pos = self.plot_item.vb.mapSceneToView(scene_pos)
x, y = view_pos.x(), view_pos.y()
if not self._is_within_view_range(x, y):
return
# Update crosshair visuals
self.v_line.setPos(x)
@@ -594,8 +594,12 @@ class Crosshair(QObject):
if event.button() != Qt.MouseButton.LeftButton:
return
self.update_markers()
if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos):
mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos)
scene_pos_getter = getattr(event, "scenePos", None)
if not callable(scene_pos_getter):
return
scene_pos = scene_pos_getter()
mouse_point = self.plot_item.vb.mapSceneToView(scene_pos)
if self._is_within_view_range(mouse_point.x(), mouse_point.y()):
x, y = mouse_point.x(), mouse_point.y()
# Keep the raw click position; ``x``/``y`` are reused/snapped below.
pin_x, pin_y = mouse_point.x(), mouse_point.y()
@@ -652,7 +656,7 @@ class Crosshair(QObject):
# removes it, otherwise (re)place a single pin at the clicked point.
if event.double():
self.clear_pin()
elif self._pin_hit(event._scenePos):
elif self._pin_hit(scene_pos):
self.clear_pin()
else:
self.set_pin(pin_x, pin_y, x_snap_values, y_snap_values)
@@ -853,6 +857,10 @@ class Crosshair(QObject):
delta = pin_scene - scene_pos
return (delta.x() ** 2 + delta.y() ** 2) ** 0.5 <= self._pin_hit_radius_px
def _is_within_view_range(self, x: float, y: float) -> bool:
x_range, y_range = self.plot_item.vb.viewRange()
return min(x_range) <= x <= max(x_range) and min(y_range) <= y <= max(y_range)
def _get_transformed_position(
self, x: float, y: float, transform: QTransform
) -> tuple[QPointF, QPointF]:
+10 -3
View File
@@ -1,12 +1,19 @@
import bec_qthemes
from bec_qthemes.qss_editor.qss_editor import (
THEMES_PATH,
build_palette_from_mapping,
read_theme_xml,
)
def pretty_display_theme(theme: str = "dark"):
palette = bec_qthemes.load_palette(theme)
_, mapping = read_theme_xml(THEMES_PATH / f"{theme}.xml")
palette = build_palette_from_mapping(mapping)
foreground = palette.text().color().name()
background = palette.base().color().name()
border = palette.shadow().color().name()
accent = palette.accent().color().name()
# palette.highlight() rather than accent(): on Qt 6.10+ accent() returns the
# platform accent, and bec_qthemes palettes only set Highlight anyway.
accent = palette.highlight().color().name()
return f"""
QWidget {{color: {foreground}; background-color: {background}}}
QLabel {{ font-weight: bold; }}
@@ -1465,6 +1465,15 @@ class DockAreaWidget(BECWidget, QWidget):
for dock in self.dock_list():
self._delete_dock(dock)
def cleanup(self):
"""Tear down all docks via the Qt ADS API before the base BECWidget cleanup runs.
Explicitly releasing dock widgets through the CDockManager API first prevents its
destructor from interacting badly with dock widgets that are deleted outside of it.
"""
self.delete_all()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
@@ -144,7 +144,12 @@ class BecConsoleRegistry:
return None
window = console.window()
if window is not None and window is not console and self._is_valid_qobject(window):
if (
window is not None
and window is not console
and self._is_valid_qobject(window)
and not getattr(window, "_destroyed", False)
):
return window
if not avoid_console:
+29 -5
View File
@@ -11,7 +11,7 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_qthemes import material_icon
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, Qt, QThread, QTimer, Signal
from qtpy.QtCore import QObject, QRectF, Qt, QThread, QTimer, Signal
from qtpy.QtGui import QTransform
from qtpy.QtWidgets import QDialog, QPushButton, QVBoxLayout
from toolz import partition
@@ -93,6 +93,30 @@ class HeatmapConfig(ConnectionConfig):
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
class _TextOnlyLegendSample(pg.graphicsItems.LegendItem.ItemSample):
"""Zero-size legend sample for text-only rows in the config label.
The stock ItemSample expects a plottable item with an ``opts`` dict; since
PySide 6.10 an exception raised inside its paint() override propagates out
of the C++ paint loop and crashes the application, so the config label rows
must not carry a real sample item.
"""
def __init__(self):
super().__init__(item=None)
self.setFixedWidth(0)
self.setFixedHeight(0)
def boundingRect(self):
return QRectF(0, 0, 0, 0)
def paint(self, p, *args):
pass
def mouseClickEvent(self, event):
event.ignore()
@dataclass
class _InterpolationRequest:
"""Immutable payload describing an interpolation request for the worker thread.
@@ -977,14 +1001,14 @@ class Heatmap(ImageBase):
self.config_label.clear()
# Indicate whether the widget follows the live acquisition or is pinned to a history scan
mode = "history" if self._history_scan_id is not None else "live"
self.config_label.addItem(self.plot_item, f"Scan: {scan_msg.scan_number} ({mode})")
self.config_label.addItem(self.plot_item, f"Scan Name: {scan_msg.scan_name}")
self.config_label.addItem(_TextOnlyLegendSample(), f"Scan: {scan_msg.scan_number} ({mode})")
self.config_label.addItem(_TextOnlyLegendSample(), f"Scan Name: {scan_msg.scan_name}")
if scan_msg.scan_name != "grid_scan" or self._image_config.enforce_interpolation:
self.config_label.addItem(
self.plot_item, f"Interpolation: {self._image_config.interpolation}"
_TextOnlyLegendSample(), f"Interpolation: {self._image_config.interpolation}"
)
self.config_label.addItem(
self.plot_item, f"Oversampling: {self._image_config.oversampling_factor}x"
_TextOnlyLegendSample(), f"Oversampling: {self._image_config.oversampling_factor}x"
)
def get_image_data(
@@ -58,7 +58,9 @@ class SpinnerWidget(QWidget):
color_palette = get_theme_palette()
color = QColor(color_palette.accent().color())
# Use the theme highlight color; palette.accent() resolved to it before Qt 6.10,
# but now falls back to the platform accent since the theme does not set the role.
color = QColor(color_palette.highlight().color())
rect.adjust(line_width, line_width, -line_width, -line_width)
+3 -3
View File
@@ -10,8 +10,8 @@ classifiers = [
]
dependencies = [
"PyJWT~=2.9",
"PySide6==6.9.0",
"PySide6-QtAds==4.4.0",
"PySide6==6.11.1",
"PySide6-QtAds==5.0.0",
"bec_ipython_client~=3.134", # needed for jupyter console
"bec_lib~=3.134",
"bec_qthemes~=1.0, >=1.3.4",
@@ -23,7 +23,7 @@ dependencies = [
"ophyd_devices~=1.29, >=1.29.1",
"pydantic~=2.0",
"pylsp-bec~=1.2",
"pyqtgraph==0.13.7",
"pyqtgraph==0.14.0",
"python-slugify~=8.0",
"qtconsole~=5.5, >=5.5.1", # needed for jupyter console
"qtmonaco~=0.8, >=0.8.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+4
View File
@@ -68,6 +68,10 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus
# stop pyepics dispatcher for leaking tests
_dispatcher.stop()
from bec_widgets.widgets.editors.bec_console.bec_console import _bec_console_registry
_bec_console_registry.clear()
process_all_deferred_deletes(qapp)
if request.node.stash._storage.get("failed"):
print("Test failed, skipping cleanup checks")
return
+90 -84
View File
@@ -14,6 +14,33 @@ from .conftest import create_widget
# pylint: disable = redefined-outer-name
class _FakeClickEvent:
"""Minimal public-API stand-in for a pyqtgraph MouseClickEvent."""
def __init__(
self,
scene_pos: QPointF,
button: Qt.MouseButton = Qt.MouseButton.LeftButton,
double: bool = False,
):
self._scene_pos = scene_pos
self._button = button
self._double = double
self.accepted = False
def button(self):
return self._button
def scenePos(self):
return self._scene_pos
def double(self):
return self._double
def accept(self):
self.accepted = True
@pytest.fixture
def plot_widget_with_crosshair(qtbot):
widget = pg.PlotWidget()
@@ -22,6 +49,7 @@ def plot_widget_with_crosshair(qtbot):
widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1")
plot_item = widget.getPlotItem()
plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0)
crosshair = Crosshair(plot_item=plot_item, precision=3)
yield crosshair, plot_item
@@ -38,20 +66,17 @@ def image_widget_with_crosshair(qtbot):
widget.addItem(image_item)
plot_item = widget.getPlotItem()
plot_item.vb.setRange(xRange=(0, 100), yRange=(0, 100), padding=0)
crosshair = Crosshair(plot_item=plot_item, precision=3)
yield crosshair, plot_item
def test_mouse_moved_lines(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair, _ = plot_widget_with_crosshair
# Simulate mouse movement
crosshair.mouse_moved(event_mock)
crosshair.mouse_moved(manual_pos=(2, 5))
# Check that the vertical line is indeed at x=2
assert np.isclose(crosshair.v_line.pos().x(), 2)
@@ -59,7 +84,7 @@ def test_mouse_moved_lines(plot_widget_with_crosshair):
def test_mouse_moved_signals(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
crosshair, _ = plot_widget_with_crosshair
emitted_values_1D = []
@@ -68,43 +93,40 @@ def test_mouse_moved_signals(plot_widget_with_crosshair):
crosshair.coordinatesChanged1D.connect(slot)
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair.mouse_moved(event_mock)
crosshair.mouse_moved(manual_pos=(2, 5))
# Assert the expected behavior
assert emitted_values_1D == [("Curve 1", 2, 5)]
def test_mouse_moved_signals_outside(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
crosshair, _ = plot_widget_with_crosshair
# Create a slot that will store the emitted values as tuples
emitted_values_1D = []
emitted_positions = []
def slot(coordinates):
emitted_values_1D.append(coordinates)
# Connect the signal to the custom slot
crosshair.coordinatesChanged1D.connect(slot)
crosshair.crosshairChanged.connect(emitted_positions.append)
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(22, 55)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
crosshair.mouse_moved(event_mock)
crosshair.mouse_moved(manual_pos=(2, 5))
emitted_positions.clear()
emitted_values_1D.clear()
crosshair.mouse_moved(manual_pos=(22, 55))
# Assert the expected behavior
assert emitted_values_1D == []
assert emitted_positions == []
assert np.isclose(crosshair.v_line.pos().x(), 2)
assert np.isclose(crosshair.h_line.pos().y(), 5)
def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair, plot_item = image_widget_with_crosshair
image_item = plot_item.items[0]
crosshair, _ = image_widget_with_crosshair
emitted_values_2D = []
@@ -113,17 +135,16 @@ def test_mouse_moved_signals_2D(image_widget_with_crosshair):
crosshair.coordinatesChanged2D.connect(slot)
pos_in_view = QPointF(21.0, 55.0)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair.mouse_moved(event_mock)
crosshair.mouse_moved(manual_pos=(21.0, 55.0))
assert emitted_values_2D == [("ImageItem", 21, 55)]
def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
def test_mouse_moved_signals_2D_outside_image_bounds_clamps_inside_view_range(
image_widget_with_crosshair,
):
crosshair, plot_item = image_widget_with_crosshair
plot_item.vb.setRange(xRange=(0, 300), yRange=(0, 600), padding=0)
emitted_values_2D = []
@@ -132,23 +153,34 @@ def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair):
crosshair.coordinatesChanged2D.connect(slot)
pos_in_view = QPointF(220.0, 555.0)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair.mouse_moved(manual_pos=(220.0, 555.0))
crosshair.mouse_moved(event_mock)
assert emitted_values_2D == [("ImageItem", 99, 99)]
def test_mouse_moved_signals_2D_outside_view_range_ignored(image_widget_with_crosshair):
crosshair, _ = image_widget_with_crosshair
emitted_values_2D = []
emitted_positions = []
crosshair.coordinatesChanged2D.connect(emitted_values_2D.append)
crosshair.crosshairChanged.connect(emitted_positions.append)
crosshair.mouse_moved(manual_pos=(21.0, 55.0))
emitted_positions.clear()
emitted_values_2D.clear()
crosshair.mouse_moved(manual_pos=(220.0, 555.0))
assert emitted_values_2D == []
assert emitted_positions == []
assert np.isclose(crosshair.v_line.pos().x(), 21.0)
assert np.isclose(crosshair.h_line.pos().y(), 55.0)
def test_marker_positions_after_mouse_move(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair.mouse_moved(event_mock)
crosshair, _ = plot_widget_with_crosshair
crosshair.mouse_moved(manual_pos=(2, 5))
marker = crosshair.marker_moved_1d["Curve 1"]
marker_x, marker_y = marker.getData()
@@ -172,7 +204,7 @@ def test_scale_emitted_coordinates(plot_widget_with_crosshair):
def test_crosshair_changed_signal(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
crosshair, _ = plot_widget_with_crosshair
emitted_positions = []
@@ -181,11 +213,7 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
crosshair.crosshairChanged.connect(slot)
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
crosshair.mouse_moved(event_mock)
crosshair.mouse_moved(manual_pos=(2, 5))
x, y = emitted_positions[0]
@@ -193,33 +221,33 @@ def test_crosshair_changed_signal(plot_widget_with_crosshair):
assert np.isclose(y, 5)
def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair):
def test_crosshair_clicked_signal(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
emitted_positions = []
emitted_view_positions = []
def slot(position):
emitted_positions.append(position)
crosshair.crosshairClicked.connect(slot)
crosshair.positionClicked.connect(emitted_view_positions.append)
x_data = 2
y_data = 5
crosshair.is_log_x = True
crosshair.is_log_y = True
plot_item.vb.setRange(xRange=(0, 1), yRange=(0, 1), padding=0)
# Map data coordinates to scene coordinates
pos_in_scene = plot_item.vb.mapViewToScene(QPointF(x_data, y_data))
# Map scene coordinates to widget coordinates
graphics_view = plot_item.vb.scene().views()[0]
qtbot.waitExposed(graphics_view)
pos_in_widget = graphics_view.mapFromScene(pos_in_scene)
# Simulate mouse click
qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos_in_widget)
known_view_point = QPointF(np.log10(2), np.log10(5))
pos_in_scene = plot_item.vb.mapViewToScene(known_view_point)
crosshair.mouse_clicked(_FakeClickEvent(pos_in_scene))
x, y = emitted_positions[0]
view_x, view_y = emitted_view_positions[0]
assert np.isclose(round(x, 1), 2)
assert np.isclose(round(y, 1), 5)
assert np.isclose(x, 2)
assert np.isclose(y, 5)
assert np.isclose(view_x, known_view_point.x())
assert np.isclose(view_y, known_view_point.y())
def test_update_coord_label_1D(plot_widget_with_crosshair):
@@ -372,21 +400,18 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client):
c0 = wf.plot(x=[1, 2, 3], y=[1, 4, 9], name="Curve_0")
c1 = wf.plot(x=[1, 2, 3], y=[2, 5, 10], name="Curve_1")
wf.hook_crosshair()
wf.crosshair.plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0)
# # Simulate a mouse move at (2,5)
pos_in_view = QPointF(2, 5)
pos_in_scene = wf.plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# 1) Both curves visible: expect markers for both
wf.crosshair.clear_markers()
wf.crosshair.mouse_moved(event_mock)
wf.crosshair.mouse_moved(manual_pos=(2, 5))
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0", "Curve_1"}
# 2) Hide Curve B and repeat: only Curve_0 should remain
c1.setVisible(False)
wf.crosshair.clear_markers()
wf.crosshair.mouse_moved(event_mock)
wf.crosshair.mouse_moved(manual_pos=(2, 5))
qtbot.wait(200)
assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"}
@@ -396,25 +421,6 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client):
###############################################
class _FakeClickEvent:
"""Minimal stand-in for a pyqtgraph MouseClickEvent for routing tests."""
def __init__(self, scene_pos, button=Qt.LeftButton, double=False):
self._scenePos = scene_pos
self._button = button
self._double = double
self.accepted = False
def button(self):
return self._button
def double(self):
return self._double
def accept(self):
self.accepted = True
def test_set_pin_1d_creates_marker_and_emits(plot_widget_with_crosshair):
crosshair, _ = plot_widget_with_crosshair
pinned = []
@@ -531,6 +531,7 @@ class TestDeviceManagerView:
# Assign the mocked client
widget.device_manager_widget.client = mocked_client
qtbot.addWidget(widget)
widget.show()
qtbot.waitExposed(widget)
yield widget
+22
View File
@@ -13,6 +13,7 @@ from bec_widgets.widgets.plots.heatmap.heatmap import (
HeatmapDeviceSignal,
_InterpolationRequest,
_StepInterpolationWorker,
_TextOnlyLegendSample,
)
# pytest: disable=unused-import
@@ -1055,6 +1056,27 @@ def test_heatmap_config_label_shows_live_or_history(heatmap_widget):
assert "Scan: 5 (history)" in labels
def test_heatmap_config_label_paints_without_error(heatmap_widget):
"""The config label rows must survive a real paint pass.
A stock ItemSample around a PlotItem raises in paint(); since PySide 6.10
override exceptions propagate out of the C++ paint loop and segfault.
"""
scan_msg = mock.MagicMock()
scan_msg.scan_number = 5
scan_msg.scan_name = "line_scan"
heatmap_widget.status_message = scan_msg
heatmap_widget._image_config.show_config_label = True
heatmap_widget.redraw_config_label()
samples = [sample for sample, _ in heatmap_widget.config_label.items]
assert samples
assert all(isinstance(sample, _TextOnlyLegendSample) for sample in samples)
pixmap = heatmap_widget.grab()
assert not pixmap.isNull()
def test_heatmap_settings_scan_index_syncs_with_widget(heatmap_widget, qtbot, scan_history_factory):
"""
The scan index combobox in the settings dialog mirrors the heatmap state:
@@ -1214,6 +1214,9 @@ def test_crosshair_moves_update_profiles_immediately(qtbot, mocked_client):
switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair")
switch.actions["crosshair_roi"].action.trigger()
qtbot.wait(50)
bec_image_view.crosshair.plot_item.vb.setRange(
xRange=(0, test_data.shape[0]), yRange=(0, test_data.shape[1]), padding=0
)
# Position the crosshair lines and emit the snapped pixel (as mouse_moved does).
bec_image_view.crosshair.mouse_moved(manual_pos=(1.2, 2.2))
@@ -1233,6 +1236,9 @@ def test_live_label_intensity_updates_on_image_update(qtbot, mocked_client):
test_data = np.arange(25, dtype=float).reshape(5, 5)
bec_image_view.on_image_update_2d({"data": test_data}, {})
bec_image_view.hook_crosshair()
bec_image_view.crosshair.plot_item.vb.setRange(
xRange=(0, test_data.shape[0]), yRange=(0, test_data.shape[1]), padding=0
)
bec_image_view.crosshair.mouse_moved(manual_pos=(2.5, 3.5))
assert "Intensity: 13.000" in bec_image_view.crosshair.coord_label.toPlainText()
+1 -1
View File
@@ -80,7 +80,7 @@ def test_export_import_config(example_widget):
"value": 10,
},
"QTableWidget ()": {
"QAbstractButton ()": {},
"QAbstractButton (qt_tableview_cornerbutton)": {},
"QAbstractTableModel ()": {},
"QHeaderView ()": {
"QItemSelectionModel ()": {},