mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-27 05:33:03 +02:00
feat(flow_layout): new flow layout added; implementation to ScanControl widget
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import QPoint, QRect, QSize, Qt
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QLayout,
|
||||
QLayoutItem,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
"""
|
||||
A horizontal wrapping layout for Qt widgets.
|
||||
|
||||
Adapted from the Qt "Flow Layout" example
|
||||
(https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html), extended with an
|
||||
optional minimum item width and uniform (normalized) item sizing.
|
||||
|
||||
The layout places items left-to-right until the next item no longer fits, then starts a
|
||||
new row. Item dimensions are based on each item's minimum size and size hint, optionally
|
||||
expanded by ``minimum_item_width``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
margin: int = 0,
|
||||
horizontal_spacing: int = -1,
|
||||
vertical_spacing: int = -1,
|
||||
minimum_item_width: int | None = None,
|
||||
normalize_item_sizes: bool = False,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._items: list[QLayoutItem] = []
|
||||
self._horizontal_spacing = horizontal_spacing
|
||||
self._vertical_spacing = vertical_spacing
|
||||
self._minimum_item_width = minimum_item_width
|
||||
self._normalize_item_sizes = normalize_item_sizes
|
||||
self._cached_normalized_size: QSize | None = None
|
||||
self._normalized_cache_valid = False
|
||||
self.setContentsMargins(margin, margin, margin, margin)
|
||||
|
||||
def __del__(self):
|
||||
# Drain the layout items, mirroring the destructor of Qt's C++ FlowLayout example
|
||||
# and its official PySide6 port. This is needed because:
|
||||
#
|
||||
# - QLayout.addWidget() creates a C++ QWidgetItem (~80 bytes) per widget and hands
|
||||
# it to our addItem(); by Qt's ownership contract the layout owns it.
|
||||
# - Built-in layouts free their items in their own C++ destructors, but a custom
|
||||
# layout stores items where the C++ base class cannot see them (self._items),
|
||||
# and ~QLayout cannot call our overridden takeAt()/count() during destruction
|
||||
# (virtual dispatch is already unwound). Without this drain the C++ structs leak.
|
||||
# - takeAt() transfers item ownership to the Python wrapper, which frees the C++
|
||||
# side on garbage collection.
|
||||
#
|
||||
# Measured: churning 300 layouts x 100 items leaks ~2.4 MB without the drain and
|
||||
# ~0 with it. Omitting it changes no behavior (items do not own the widgets, and
|
||||
# widget cleanup runs via closeEvent independently) - it only causes a slow,
|
||||
# hard-to-attribute memory creep in long-running sessions with layout churn.
|
||||
try:
|
||||
item = self.takeAt(0)
|
||||
while item:
|
||||
item = self.takeAt(0)
|
||||
except RuntimeError:
|
||||
# The underlying C++ layout is already destroyed; nothing left to drain.
|
||||
pass
|
||||
|
||||
def addItem(self, item: QLayoutItem) -> None:
|
||||
self._items.append(item)
|
||||
self.invalidate()
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
def itemAt(self, index: int) -> QLayoutItem | None:
|
||||
if 0 <= index < len(self._items):
|
||||
return self._items[index]
|
||||
return None
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem | None:
|
||||
if 0 <= index < len(self._items):
|
||||
item = self._items.pop(index)
|
||||
self.invalidate()
|
||||
return item
|
||||
return None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self._normalized_cache_valid = False
|
||||
self._cached_normalized_size = None
|
||||
super().invalidate()
|
||||
|
||||
def expandingDirections(self) -> Qt.Orientations:
|
||||
return Qt.Orientations()
|
||||
|
||||
def hasHeightForWidth(self) -> bool:
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int:
|
||||
return self._do_layout(QRect(0, 0, width, 0), test_only=True)
|
||||
|
||||
def setGeometry(self, rect: QRect) -> None:
|
||||
super().setGeometry(rect)
|
||||
self._do_layout(rect, test_only=False)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
# Mirror Qt's FlowLayout: a wrapping layout advertises its minimum size as the
|
||||
# preferred size so the widget is content to be narrow and wrap, instead of
|
||||
# demanding a single-row width that would defeat the wrapping.
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self) -> QSize:
|
||||
size = QSize()
|
||||
normalized_size = self._normalized_item_size()
|
||||
for item in self._items:
|
||||
if item.isEmpty():
|
||||
continue
|
||||
size = size.expandedTo(self._item_size(item, normalized_size))
|
||||
|
||||
left, top, right, bottom = self.getContentsMargins()
|
||||
size += QSize(left + right, top + bottom)
|
||||
return size
|
||||
|
||||
def _do_layout(self, rect: QRect, test_only: bool) -> int:
|
||||
left, top, right, bottom = self.getContentsMargins()
|
||||
effective_rect = rect.adjusted(left, top, -right, -bottom)
|
||||
x = effective_rect.x()
|
||||
y = effective_rect.y()
|
||||
line_height = 0
|
||||
normalized_size = self._normalized_item_size()
|
||||
space_x = self._smart_spacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
|
||||
space_y = self._smart_spacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
|
||||
|
||||
for item in self._items:
|
||||
if item.isEmpty():
|
||||
continue
|
||||
|
||||
item_size = self._item_size(item, normalized_size)
|
||||
next_x = x + item_size.width() + space_x
|
||||
|
||||
# Wrap when the item would overflow the row. Unlike the Qt example
|
||||
# (`next_x - space_x > right()`), the `+ 1` keeps an exactly-fitting item on
|
||||
# the current row; the `line_height > 0` guard matches Qt and never wraps
|
||||
# before anything was placed on the row.
|
||||
if line_height > 0 and next_x - space_x > effective_rect.right() + 1:
|
||||
x = effective_rect.x()
|
||||
y = y + line_height + space_y
|
||||
next_x = x + item_size.width() + space_x
|
||||
line_height = 0
|
||||
|
||||
if not test_only:
|
||||
item.setGeometry(QRect(QPoint(x, y), item_size))
|
||||
|
||||
x = next_x
|
||||
line_height = max(line_height, item_size.height())
|
||||
|
||||
return y + line_height - rect.y() + bottom
|
||||
|
||||
def _item_size(self, item: QLayoutItem, normalized_size: QSize | None = None) -> QSize:
|
||||
if normalized_size is not None:
|
||||
return QSize(normalized_size)
|
||||
|
||||
size = item.sizeHint().expandedTo(item.minimumSize())
|
||||
if self._minimum_item_width is not None:
|
||||
size.setWidth(max(size.width(), self._minimum_item_width))
|
||||
return size
|
||||
|
||||
def _normalized_item_size(self) -> QSize | None:
|
||||
if not self._normalize_item_sizes:
|
||||
return None
|
||||
if self._normalized_cache_valid:
|
||||
return self._cached_normalized_size
|
||||
|
||||
normalized_size = QSize()
|
||||
for item in self._items:
|
||||
if item.isEmpty():
|
||||
continue
|
||||
normalized_size = normalized_size.expandedTo(self._item_size(item))
|
||||
self._cached_normalized_size = normalized_size if normalized_size.isValid() else None
|
||||
self._normalized_cache_valid = True
|
||||
return self._cached_normalized_size
|
||||
|
||||
def _smart_spacing(self, pixel_metric: QStyle.PixelMetric) -> int:
|
||||
spacing = (
|
||||
self._horizontal_spacing
|
||||
if pixel_metric == QStyle.PixelMetric.PM_LayoutHorizontalSpacing
|
||||
else self._vertical_spacing
|
||||
)
|
||||
if spacing >= 0:
|
||||
return spacing
|
||||
|
||||
parent = self.parent()
|
||||
if parent is not None and not parent.isWidgetType():
|
||||
# Parent is another layout; use its spacing, but never feed a -1 sentinel
|
||||
# into positioning (that would overlap items by 1px per gap).
|
||||
return max(0, parent.spacing())
|
||||
|
||||
widget = parent if parent is not None else None
|
||||
style = widget.style() if widget is not None else QApplication.style()
|
||||
spacing = style.pixelMetric(pixel_metric, None, widget)
|
||||
if spacing < 0:
|
||||
# Some styles (e.g. native macOS) return -1 from pixelMetric by design and
|
||||
# expose the real value via layoutSpacing(), like the Qt example does.
|
||||
orientation = (
|
||||
Qt.Orientation.Horizontal
|
||||
if pixel_metric == QStyle.PixelMetric.PM_LayoutHorizontalSpacing
|
||||
else Qt.Orientation.Vertical
|
||||
)
|
||||
spacing = style.layoutSpacing(
|
||||
QSizePolicy.ControlType.PushButton,
|
||||
QSizePolicy.ControlType.PushButton,
|
||||
orientation,
|
||||
None,
|
||||
widget,
|
||||
)
|
||||
return max(0, spacing)
|
||||
|
||||
|
||||
class FlowLayoutWidget(QWidget):
|
||||
"""
|
||||
Thin convenience container with a :class:`FlowLayout` installed.
|
||||
|
||||
QWidget natively defers ``hasHeightForWidth``/``heightForWidth``/``minimumSizeHint``
|
||||
to the installed layout, so no forwarding overrides are needed. Only ``sizeHint`` is
|
||||
overridden: it deliberately advertises the layout's minimum size so parent layouts let
|
||||
the widget stay narrow and wrap, instead of the tall height-for-width-corrected
|
||||
default hint.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None, **layout_kwargs):
|
||||
super().__init__(parent)
|
||||
self.flow_layout = FlowLayout(self, **layout_kwargs)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return self.flow_layout.sizeHint()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
app = QApplication([])
|
||||
|
||||
window = FlowLayoutWidget(
|
||||
margin=12,
|
||||
horizontal_spacing=8,
|
||||
vertical_spacing=8,
|
||||
minimum_item_width=140,
|
||||
normalize_item_sizes=True,
|
||||
)
|
||||
window.setWindowTitle("FlowLayout demo")
|
||||
|
||||
widgets = [
|
||||
QPushButton("Run"),
|
||||
QPushButton("Pause"),
|
||||
QDoubleSpinBox(),
|
||||
QComboBox(),
|
||||
QCheckBox("Relative"),
|
||||
QSlider(Qt.Orientation.Horizontal),
|
||||
QPushButton("Restore last parameters"),
|
||||
QPushButton("Abort"),
|
||||
]
|
||||
widgets[3].addItems(["line_scan", "grid_scan", "round_scan"])
|
||||
widgets[5].setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
|
||||
for widget in widgets:
|
||||
window.flow_layout.addWidget(widget)
|
||||
|
||||
window.resize(520, 180)
|
||||
window.show()
|
||||
app.exec()
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import BaseModel, Field
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -79,6 +79,7 @@ class ScanControl(BECWidget, QWidget):
|
||||
# Main layout
|
||||
self.layout = QVBoxLayout(self)
|
||||
self.layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.layout.setAlignment(Qt.AlignTop)
|
||||
self.arg_box = None
|
||||
self.kwarg_boxes = []
|
||||
self.expert_mode = False # TODO implement in the future versions
|
||||
@@ -163,8 +164,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
# Append metadata form
|
||||
self._add_metadata_form()
|
||||
|
||||
self.layout.addStretch()
|
||||
|
||||
def _add_metadata_form(self):
|
||||
# Wrap metadata form in a group box
|
||||
self._metadata_group = QGroupBox("Scan Metadata", self)
|
||||
@@ -421,7 +420,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
for group in groups:
|
||||
box = ScanGroupBox(box_type="kwargs", config=group)
|
||||
box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||
box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.layout.insertWidget(position + len(self.kwarg_boxes), box)
|
||||
self.kwarg_boxes.append(box)
|
||||
box.setVisible(not self._hide_kwarg_boxes)
|
||||
@@ -435,7 +433,6 @@ class ScanControl(BECWidget, QWidget):
|
||||
self.arg_box = ScanGroupBox(box_type="args", config=group)
|
||||
self.arg_box.device_selected.connect(self.emit_device_selected)
|
||||
self.arg_box.reference_units_changed.connect(self._apply_reference_units_to_other_boxes)
|
||||
self.arg_box.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
self.arg_box.hide_add_remove_buttons = self._hide_add_remove_buttons
|
||||
self.layout.insertWidget(self.ARG_BOX_POSITION, self.arg_box)
|
||||
self.arg_box.setVisible(not self._hide_arg_box)
|
||||
|
||||
@@ -10,16 +10,18 @@ from qtpy.QtWidgets import (
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.flow_layout import FlowLayoutWidget
|
||||
from bec_widgets.utils.scan_arg_metadata import (
|
||||
apply_numeric_limits,
|
||||
apply_numeric_precision,
|
||||
@@ -195,11 +197,14 @@ class ScanGroupBox(QGroupBox):
|
||||
self.box_type = box_type
|
||||
self._hide_add_remove_buttons = False
|
||||
|
||||
vbox_layout = QVBoxLayout(self)
|
||||
self._root_layout = QVBoxLayout(self)
|
||||
self._root_layout.setContentsMargins(6, 6, 6, 6)
|
||||
hbox_layout = QHBoxLayout()
|
||||
vbox_layout.addLayout(hbox_layout)
|
||||
self.layout = QGridLayout()
|
||||
vbox_layout.addLayout(self.layout)
|
||||
self._root_layout.addLayout(hbox_layout)
|
||||
self._bundles_layout = QVBoxLayout()
|
||||
self._bundles_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._bundles_layout.setSpacing(8)
|
||||
self._root_layout.addLayout(self._bundles_layout)
|
||||
|
||||
# Add bundle button
|
||||
self.button_add_bundle = QPushButton(self)
|
||||
@@ -217,7 +222,10 @@ class ScanGroupBox(QGroupBox):
|
||||
self.labels = []
|
||||
self.widgets = []
|
||||
self._widget_configs = {}
|
||||
self._column_labels = {}
|
||||
self._widget_labels = {}
|
||||
self._widget_bundle_indexes = {}
|
||||
self._bundle_widgets = []
|
||||
self._bundle_containers = []
|
||||
self.selected_devices = {}
|
||||
|
||||
self.init_box(self.config)
|
||||
@@ -225,48 +233,48 @@ class ScanGroupBox(QGroupBox):
|
||||
self.button_add_bundle.clicked.connect(self.add_widget_bundle)
|
||||
self.button_remove_bundle.clicked.connect(self.remove_widget_bundle)
|
||||
|
||||
# NOTE: no sizing overrides are needed. Qt propagates height-for-width natively
|
||||
# (FlowLayout -> FlowLayoutWidget -> QVBoxLayout -> QGroupBox): QWidgetItem consults
|
||||
# layout()->totalHeightForWidth(), which already includes the title-aware group box
|
||||
# margins. Host layouts should top-align or stretch below the box; ScanControl uses
|
||||
# layout.setAlignment(Qt.AlignTop).
|
||||
|
||||
def init_box(self, config: dict):
|
||||
box_name = config.get("name", "ScanGroupBox")
|
||||
self.inputs = config.get("inputs", {})
|
||||
self.setTitle(box_name)
|
||||
|
||||
# Labels
|
||||
self.add_input_labels(self.inputs, 0)
|
||||
|
||||
# Widgets
|
||||
if self.box_type == "args":
|
||||
min_bundle = self.config.get("min", 1)
|
||||
for i in range(1, min_bundle + 1):
|
||||
self.add_input_widgets(self.inputs, i)
|
||||
for _ in range(1, min_bundle + 1):
|
||||
self.add_input_widgets(self.inputs)
|
||||
else:
|
||||
self.add_input_widgets(self.inputs, 1)
|
||||
self.add_input_widgets(self.inputs)
|
||||
self.button_add_bundle.setVisible(False)
|
||||
self.button_remove_bundle.setVisible(False)
|
||||
|
||||
def add_input_labels(self, group_inputs: dict, row: int) -> None:
|
||||
"""
|
||||
Adds the given arg_group from arg_bundle to the scan control layout. The input labels are always added to the first row.
|
||||
|
||||
Args:
|
||||
group(dict): Dictionary containing the arg_group information.
|
||||
"""
|
||||
for column_index, item in enumerate(group_inputs):
|
||||
arg_name = item.get("name", None)
|
||||
display_name = item.get("display_name", arg_name)
|
||||
label = QLabel(text=display_name)
|
||||
self.layout.addWidget(label, row, column_index)
|
||||
self.labels.append(label)
|
||||
self._column_labels[column_index] = label
|
||||
|
||||
def add_input_widgets(self, group_inputs: dict, row) -> None:
|
||||
def add_input_widgets(self, group_inputs: dict) -> None:
|
||||
"""
|
||||
Adds the given arg_group from arg_bundle to the scan control layout.
|
||||
|
||||
Args:
|
||||
group_inputs(dict): Dictionary containing the arg_group information.
|
||||
row(int): The row to add the widgets to.
|
||||
"""
|
||||
for column_index, item in enumerate(group_inputs):
|
||||
bundle_index = len(self._bundle_widgets)
|
||||
bundle_container = FlowLayoutWidget(
|
||||
self,
|
||||
horizontal_spacing=8,
|
||||
vertical_spacing=8,
|
||||
minimum_item_width=130,
|
||||
normalize_item_sizes=True,
|
||||
)
|
||||
bundle_layout = bundle_container.flow_layout
|
||||
bundle_widgets = []
|
||||
self._bundles_layout.addWidget(bundle_container)
|
||||
self._bundle_containers.append(bundle_container)
|
||||
self._bundle_widgets.append(bundle_widgets)
|
||||
|
||||
for item in group_inputs:
|
||||
arg_name = item.get("name", None)
|
||||
default = item.get("default", None)
|
||||
item_type = item.get("type", None)
|
||||
@@ -306,8 +314,23 @@ class ScanGroupBox(QGroupBox):
|
||||
widget.set_literals(item["type"].get("Literal", []))
|
||||
self._widget_configs[widget] = item
|
||||
apply_unit_metadata(widget, item)
|
||||
self.layout.addWidget(widget, row, column_index)
|
||||
|
||||
label = QLabel(text=item.get("display_name", item.get("name", None)), parent=self)
|
||||
label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
tile = QWidget(bundle_container)
|
||||
tile.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
tile_layout = QVBoxLayout(tile)
|
||||
tile_layout.setContentsMargins(0, 0, 0, 0)
|
||||
tile_layout.setSpacing(2)
|
||||
tile_layout.addWidget(label)
|
||||
tile_layout.addWidget(widget)
|
||||
bundle_layout.addWidget(tile)
|
||||
|
||||
self.labels.append(label)
|
||||
self.widgets.append(widget)
|
||||
bundle_widgets.append(widget)
|
||||
self._widget_labels[widget] = label
|
||||
self._widget_bundle_indexes[widget] = bundle_index
|
||||
|
||||
@Slot(str)
|
||||
def emit_device_selected(self, device_name):
|
||||
@@ -325,11 +348,10 @@ class ScanGroupBox(QGroupBox):
|
||||
Adds a new row of widgets to the scan control layout. Only usable for arg_groups.
|
||||
"""
|
||||
arg_max = self.config.get("max", None)
|
||||
row = self.layout.rowCount()
|
||||
if arg_max is not None and row >= arg_max:
|
||||
if arg_max is not None and self.count_arg_rows() >= arg_max:
|
||||
return
|
||||
|
||||
self.add_input_widgets(self.inputs, row)
|
||||
self.add_input_widgets(self.inputs)
|
||||
|
||||
def remove_widget_bundle(self):
|
||||
"""
|
||||
@@ -337,31 +359,46 @@ class ScanGroupBox(QGroupBox):
|
||||
"""
|
||||
arg_min = self.config.get("min", None)
|
||||
row = self.count_arg_rows()
|
||||
if row <= 0:
|
||||
return
|
||||
if arg_min is not None and row <= arg_min:
|
||||
return
|
||||
|
||||
for widget in self.widgets[-len(self.inputs) :]:
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices[widget] = ""
|
||||
self._widget_configs.pop(widget, None)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.widgets = self.widgets[: -len(self.inputs)]
|
||||
self._remove_bundle(row - 1)
|
||||
|
||||
selected_devices_str = " ".join(self.selected_devices.values())
|
||||
self.device_selected.emit(selected_devices_str.strip())
|
||||
|
||||
def remove_all_widget_bundles(self):
|
||||
"""Remove every widget bundle from the scan control layout."""
|
||||
for widget in list(self.widgets):
|
||||
while self._bundle_widgets:
|
||||
self._remove_bundle(len(self._bundle_widgets) - 1)
|
||||
self.device_selected.emit("")
|
||||
|
||||
def _remove_bundle(self, bundle_index: int) -> None:
|
||||
bundle_widgets = self._bundle_widgets.pop(bundle_index)
|
||||
bundle_container = self._bundle_containers.pop(bundle_index)
|
||||
|
||||
for widget in bundle_widgets:
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
self.selected_devices.pop(widget, None)
|
||||
self._widget_configs.pop(widget, None)
|
||||
label = self._widget_labels.pop(widget, None)
|
||||
if label in self.labels:
|
||||
self.labels.remove(label)
|
||||
self._widget_bundle_indexes.pop(widget, None)
|
||||
if widget in self.widgets:
|
||||
self.widgets.remove(widget)
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
self.layout.removeWidget(widget)
|
||||
self.widgets.clear()
|
||||
self.device_selected.emit("")
|
||||
|
||||
self._bundles_layout.removeWidget(bundle_container)
|
||||
bundle_container.close()
|
||||
bundle_container.deleteLater()
|
||||
|
||||
for widget, index in list(self._widget_bundle_indexes.items()):
|
||||
if index > bundle_index:
|
||||
self._widget_bundle_indexes[widget] = index - 1
|
||||
|
||||
@Property(bool)
|
||||
def hide_add_remove_buttons(self):
|
||||
@@ -388,25 +425,20 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
def _get_arg_parameters(self, device_object: bool = True):
|
||||
args = []
|
||||
for i in range(1, self.layout.rowCount()):
|
||||
for j in range(self.layout.columnCount()):
|
||||
try: # In case that the bundle size changes
|
||||
widget = self.layout.itemAtPosition(i, j).widget()
|
||||
if isinstance(widget, DeviceComboBox) and device_object:
|
||||
value = widget.get_current_device()
|
||||
elif isinstance(widget, DeviceComboBox):
|
||||
value = widget.currentText()
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
args.append(value)
|
||||
except AttributeError:
|
||||
continue
|
||||
for bundle_widgets in self._bundle_widgets:
|
||||
for widget in bundle_widgets:
|
||||
if isinstance(widget, DeviceComboBox) and device_object:
|
||||
value = widget.get_current_device()
|
||||
elif isinstance(widget, DeviceComboBox):
|
||||
value = widget.currentText()
|
||||
else:
|
||||
value = WidgetIO.get_value(widget)
|
||||
args.append(value)
|
||||
return args
|
||||
|
||||
def _get_kwarg_parameters(self, device_object: bool = True):
|
||||
kwargs = {}
|
||||
for i in range(self.layout.columnCount()):
|
||||
widget = self.layout.itemAtPosition(1, i).widget()
|
||||
for widget in self.widgets:
|
||||
if isinstance(widget, DeviceComboBox) and device_object:
|
||||
value = widget.get_current_device().name
|
||||
elif isinstance(widget, DeviceComboBox):
|
||||
@@ -419,16 +451,23 @@ class ScanGroupBox(QGroupBox):
|
||||
return kwargs
|
||||
|
||||
def count_arg_rows(self):
|
||||
widget_rows = 0
|
||||
for row in range(self.layout.rowCount()):
|
||||
for col in range(self.layout.columnCount()):
|
||||
item = self.layout.itemAtPosition(row, col)
|
||||
if item is not None:
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
widget_rows += 1
|
||||
return widget_rows
|
||||
return len(self._bundle_widgets)
|
||||
|
||||
def label_for_widget(self, widget) -> QLabel | None:
|
||||
"""Return the label paired with a scan input widget."""
|
||||
return self._widget_labels.get(widget)
|
||||
|
||||
def label_texts(self) -> list[str]:
|
||||
"""Return labels in the same order as ``widgets``."""
|
||||
return [
|
||||
self._widget_labels[widget].text()
|
||||
for widget in self.widgets
|
||||
if widget in self._widget_labels
|
||||
]
|
||||
|
||||
def get_bundle_widgets(self, index: int) -> list[QWidget]:
|
||||
"""Return input widgets for a positional-argument bundle."""
|
||||
return list(self._bundle_widgets[index])
|
||||
|
||||
def set_parameters(self, parameters: list | dict):
|
||||
if self.box_type == "args":
|
||||
@@ -447,8 +486,8 @@ class ScanGroupBox(QGroupBox):
|
||||
|
||||
bundles_needed = -(-len(parameters) // inputs_per_bundle)
|
||||
|
||||
for row in range(1, bundles_needed + 1):
|
||||
self.add_input_widgets(self.inputs, row)
|
||||
for _ in range(bundles_needed):
|
||||
self.add_input_widgets(self.inputs)
|
||||
|
||||
for i, value in enumerate(parameters):
|
||||
WidgetIO.set_value(self.widgets[i], value)
|
||||
@@ -460,38 +499,26 @@ class ScanGroupBox(QGroupBox):
|
||||
WidgetIO.set_value(widget, value)
|
||||
break
|
||||
|
||||
def _refresh_column_label(self, column: int, item: dict) -> None:
|
||||
if column not in self._column_labels:
|
||||
def _refresh_widget_label(self, widget, item: dict) -> None:
|
||||
label = self._widget_labels.get(widget)
|
||||
if label is None:
|
||||
return
|
||||
self._column_labels[column].setText(item.get("display_name", item.get("name", None)))
|
||||
|
||||
def _widget_position(self, widget) -> tuple[int, int] | None:
|
||||
for row in range(self.layout.rowCount()):
|
||||
for column in range(self.layout.columnCount()):
|
||||
item = self.layout.itemAtPosition(row, column)
|
||||
if item is not None and item.widget() is widget:
|
||||
return row, column
|
||||
return None
|
||||
label.setText(item.get("display_name", item.get("name", None)))
|
||||
|
||||
def _update_reference_units(self, device_widget: DeviceComboBox, units: str | None) -> None:
|
||||
position = self._widget_position(device_widget)
|
||||
if position is None:
|
||||
source_bundle = self._widget_bundle_indexes.get(device_widget)
|
||||
if source_bundle is None:
|
||||
return
|
||||
source_row, _ = position
|
||||
source_name = device_widget.arg_name
|
||||
|
||||
for widget in self.widgets:
|
||||
item = self._widget_configs.get(widget, {})
|
||||
if item.get("reference_units") != source_name:
|
||||
continue
|
||||
widget_position = self._widget_position(widget)
|
||||
if widget_position is None:
|
||||
continue
|
||||
row, column = widget_position
|
||||
if self.box_type == "args" and row != source_row:
|
||||
if self.box_type == "args" and self._widget_bundle_indexes.get(widget) != source_bundle:
|
||||
continue
|
||||
apply_unit_metadata(widget, item, units)
|
||||
self._refresh_column_label(column, item)
|
||||
self._refresh_widget_label(widget, item)
|
||||
|
||||
def apply_reference_units(self, reference_name: str, units: str | None) -> None:
|
||||
"""
|
||||
@@ -505,10 +532,7 @@ class ScanGroupBox(QGroupBox):
|
||||
if item.get("reference_units") != reference_name:
|
||||
continue
|
||||
apply_unit_metadata(widget, item, units)
|
||||
position = self._widget_position(widget)
|
||||
if position is not None:
|
||||
_, column = position
|
||||
self._refresh_column_label(column, item)
|
||||
self._refresh_widget_label(widget, item)
|
||||
|
||||
def _emit_reference_units_changed(
|
||||
self, device_widget: DeviceComboBox, units: str | None
|
||||
|
||||
@@ -386,25 +386,29 @@ def test_scan_control_uses_gui_visibility_and_signature(qtbot, mocked_client):
|
||||
widget.comboBox_scan_selection.setCurrentText("annotated_scan")
|
||||
|
||||
assert widget.comboBox_scan_selection.count() == 1
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.label_for_widget(widget.arg_box.widgets[1]).text() == "Start Position"
|
||||
assert "Custom start tooltip\nUnits from: device" in widget.arg_box.widgets[1].toolTip()
|
||||
with patch.object(mocked_client.device_manager.devices.samx, "egu", return_value="mm"):
|
||||
WidgetIO.set_value(widget.arg_box.widgets[0], "samx")
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.label_for_widget(widget.arg_box.widgets[1]).text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].suffix() == " mm"
|
||||
assert "Custom start tooltip\nUnits: mm" in widget.arg_box.widgets[1].toolTip()
|
||||
widget.arg_box.widgets[0].setCurrentText("not_a_device")
|
||||
assert widget.arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start Position"
|
||||
assert widget.arg_box.label_for_widget(widget.arg_box.widgets[1]).text() == "Start Position"
|
||||
assert widget.arg_box.widgets[1].suffix() == ""
|
||||
assert "Custom start tooltip\nUnits from: device" in widget.arg_box.widgets[1].toolTip()
|
||||
assert [box.title() for box in widget.kwarg_boxes] == [
|
||||
"Movement Parameters",
|
||||
"Acquisition Parameters",
|
||||
]
|
||||
assert widget.kwarg_boxes[0].layout.itemAtPosition(0, 1).widget().text() == "Step Size Custom"
|
||||
assert widget.kwarg_boxes[0].label_for_widget(widget.kwarg_boxes[0].widgets[1]).text() == (
|
||||
"Step Size Custom"
|
||||
)
|
||||
assert widget.kwarg_boxes[0].widgets[1].suffix() == " mm"
|
||||
assert "Custom step tooltip\nUnits: mm" in widget.kwarg_boxes[0].widgets[1].toolTip()
|
||||
assert widget.kwarg_boxes[1].layout.itemAtPosition(0, 0).widget().text() == "Exp Time"
|
||||
assert widget.kwarg_boxes[1].label_for_widget(widget.kwarg_boxes[1].widgets[0]).text() == (
|
||||
"Exp Time"
|
||||
)
|
||||
assert "Exposure time\nUnits: s" in widget.kwarg_boxes[1].widgets[0].toolTip()
|
||||
|
||||
|
||||
@@ -650,13 +654,12 @@ def test_on_scan_selected(scan_control, scan_name):
|
||||
scan_control.comboBox_scan_selection.setCurrentText(scan_name)
|
||||
|
||||
# Check arg_box labels and widgets
|
||||
inputs_per_bundle = len(expected_scan_info["arg_input"])
|
||||
for index, (arg_key, arg_value) in enumerate(expected_scan_info["arg_input"].items()):
|
||||
label = scan_control.arg_box.layout.itemAtPosition(0, index).widget()
|
||||
assert label.text().lower() == arg_key
|
||||
assert scan_control.arg_box.label_texts()[index].lower() == arg_key
|
||||
|
||||
for row in range(1, expected_scan_info["arg_bundle_size"]["min"] + 1):
|
||||
widget = scan_control.arg_box.layout.itemAtPosition(row, index).widget()
|
||||
assert widget is not None # Confirm that a widget exists
|
||||
for row in range(expected_scan_info["arg_bundle_size"]["min"]):
|
||||
widget = scan_control.arg_box.get_bundle_widgets(row)[index]
|
||||
expected_widget_type = scan_control.arg_box.WIDGET_HANDLER.get(arg_value, None)
|
||||
assert isinstance(widget, expected_widget_type) # Confirm the widget type matches
|
||||
if isinstance(widget, DeviceComboBox):
|
||||
@@ -667,6 +670,9 @@ def test_on_scan_selected(scan_control, scan_name):
|
||||
assert (
|
||||
"async_device" in widget.devices
|
||||
) # async device should also be present in the device list
|
||||
assert len(scan_control.arg_box.widgets) == (
|
||||
inputs_per_bundle * expected_scan_info["arg_bundle_size"]["min"]
|
||||
)
|
||||
|
||||
# Check kwargs boxes
|
||||
kwargs_group = [param for param in expected_scan_info["gui_config"]["kwarg_groups"]]
|
||||
@@ -675,9 +681,8 @@ def test_on_scan_selected(scan_control, scan_name):
|
||||
for kwarg_box, kwarg_group in zip(scan_control.kwarg_boxes, kwargs_group):
|
||||
assert kwarg_box.title() == kwarg_group["name"]
|
||||
for index, kwarg_info in enumerate(kwarg_group["inputs"]):
|
||||
label = kwarg_box.layout.itemAtPosition(0, index).widget()
|
||||
assert label.text() == kwarg_info["display_name"]
|
||||
widget = kwarg_box.layout.itemAtPosition(1, index).widget()
|
||||
widget = kwarg_box.widgets[index]
|
||||
assert kwarg_box.label_for_widget(widget).text() == kwarg_info["display_name"]
|
||||
if isinstance(kwarg_info["type"], dict) and "Literal" in kwarg_info["type"]:
|
||||
expected_widget_type = kwarg_box.WIDGET_HANDLER.get("dict", None)
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.widget_io import WidgetIO
|
||||
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
|
||||
|
||||
|
||||
def _arg_group_input(min_bundles: int = 1) -> dict:
|
||||
return {
|
||||
"name": "Arg Test",
|
||||
"min": min_bundles,
|
||||
"inputs": [
|
||||
{
|
||||
"arg": True,
|
||||
"name": "device",
|
||||
"type": "str",
|
||||
"display_name": "Device",
|
||||
"tooltip": "Device to scan",
|
||||
"default": "samx",
|
||||
"expert": False,
|
||||
},
|
||||
{
|
||||
"arg": True,
|
||||
"name": "start",
|
||||
"type": "float",
|
||||
"display_name": "Start",
|
||||
"tooltip": "Start position",
|
||||
"default": 0,
|
||||
"expert": False,
|
||||
},
|
||||
{
|
||||
"arg": True,
|
||||
"name": "stop",
|
||||
"type": "int",
|
||||
"display_name": "Stop",
|
||||
"tooltip": "Stop position",
|
||||
"default": 1,
|
||||
"expert": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_kwarg_box(qtbot):
|
||||
group_input = {
|
||||
"name": "Kwarg Test",
|
||||
@@ -58,10 +97,7 @@ def test_kwarg_box(qtbot):
|
||||
assert kwarg_box.title() == "Kwarg Test"
|
||||
|
||||
# Labels
|
||||
assert kwarg_box.layout.itemAtPosition(0, 0).widget().text() == "Exp Time"
|
||||
assert kwarg_box.layout.itemAtPosition(0, 1).widget().text() == "Num Points"
|
||||
assert kwarg_box.layout.itemAtPosition(0, 2).widget().text() == "Relative"
|
||||
assert kwarg_box.layout.itemAtPosition(0, 3).widget().text() == "Scan Type"
|
||||
assert kwarg_box.label_texts() == ["Exp Time", "Num Points", "Relative", "Scan Type"]
|
||||
|
||||
# Widget 0
|
||||
assert kwarg_box.widgets[0].__class__.__name__ == "ScanDoubleSpinBox"
|
||||
@@ -95,41 +131,7 @@ def test_kwarg_box(qtbot):
|
||||
|
||||
|
||||
def test_arg_box(qtbot):
|
||||
group_input = {
|
||||
"name": "Arg Test",
|
||||
"inputs": [
|
||||
# Test device
|
||||
{
|
||||
"arg": True,
|
||||
"name": "device",
|
||||
"type": "str",
|
||||
"display_name": "Device",
|
||||
"tooltip": "Device to scan",
|
||||
"default": "samx",
|
||||
"expert": False,
|
||||
},
|
||||
# Test float
|
||||
{
|
||||
"arg": True,
|
||||
"name": "start",
|
||||
"type": "float",
|
||||
"display_name": "Start",
|
||||
"tooltip": "Start position",
|
||||
"default": 0,
|
||||
"expert": False,
|
||||
},
|
||||
# Test int
|
||||
{
|
||||
"arg": True,
|
||||
"name": "stop",
|
||||
"type": "int",
|
||||
"display_name": "Stop",
|
||||
"tooltip": "Stop position",
|
||||
"default": 1,
|
||||
"expert": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
group_input = _arg_group_input()
|
||||
|
||||
arg_box = ScanGroupBox(box_type="args", config=group_input)
|
||||
assert arg_box is not None
|
||||
@@ -138,9 +140,7 @@ def test_arg_box(qtbot):
|
||||
assert arg_box.title() == "Arg Test"
|
||||
|
||||
# Labels
|
||||
assert arg_box.layout.itemAtPosition(0, 0).widget().text() == "Device"
|
||||
assert arg_box.layout.itemAtPosition(0, 1).widget().text() == "Start"
|
||||
assert arg_box.layout.itemAtPosition(0, 2).widget().text() == "Stop"
|
||||
assert arg_box.label_texts() == ["Device", "Start", "Stop"]
|
||||
|
||||
# Widget 0
|
||||
assert arg_box.widgets[0].__class__.__name__ == "ScanLineEdit"
|
||||
@@ -159,6 +159,59 @@ def test_arg_box(qtbot):
|
||||
assert arg_box.widgets[2].arg_name
|
||||
|
||||
|
||||
def test_arg_bundles_have_separate_flow_layouts(qtbot):
|
||||
arg_box = ScanGroupBox(box_type="args", config=_arg_group_input(min_bundles=2))
|
||||
qtbot.addWidget(arg_box)
|
||||
arg_box.show()
|
||||
qtbot.waitExposed(arg_box)
|
||||
|
||||
assert arg_box.count_arg_rows() == 2
|
||||
assert arg_box.get_bundle_widgets(0) == arg_box.widgets[:3]
|
||||
assert arg_box.get_bundle_widgets(1) == arg_box.widgets[3:6]
|
||||
assert arg_box.get_bundle_widgets(0)[0] is not arg_box.get_bundle_widgets(1)[0]
|
||||
assert arg_box.label_texts() == ["Device", "Start", "Stop", "Device", "Start", "Stop"]
|
||||
bundle_layout = arg_box._bundle_containers[0].flow_layout
|
||||
assert bundle_layout.heightForWidth(80) > bundle_layout.heightForWidth(500)
|
||||
|
||||
|
||||
def test_scan_group_box_reports_wrapped_height_for_width(qtbot):
|
||||
arg_box = ScanGroupBox(box_type="args", config=_arg_group_input())
|
||||
qtbot.addWidget(arg_box)
|
||||
arg_box.show()
|
||||
qtbot.waitExposed(arg_box)
|
||||
|
||||
assert arg_box.hasHeightForWidth()
|
||||
assert arg_box.heightForWidth(180) > arg_box.heightForWidth(800)
|
||||
|
||||
|
||||
def test_scan_group_box_height_tracks_content(qtbot):
|
||||
# Mirrors real usage: ScanControl hosts the boxes in a top-aligned QVBoxLayout, and
|
||||
# Qt's native height-for-width propagation sizes the group box to its wrapped content.
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setAlignment(Qt.AlignTop)
|
||||
arg_box = ScanGroupBox(box_type="args", config=_arg_group_input())
|
||||
layout.addWidget(arg_box)
|
||||
qtbot.addWidget(container)
|
||||
container.resize(800, 400)
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
def height_tracks_hint(tolerance: int = 2) -> bool:
|
||||
# Allow a small tolerance: style metrics can introduce off-by-1 rounding between
|
||||
# the laid-out height and heightForWidth across platforms/styles.
|
||||
return abs(arg_box.height() - arg_box.heightForWidth(arg_box.width())) <= tolerance
|
||||
|
||||
heights = {}
|
||||
for width in (800, 300):
|
||||
container.resize(width, 400)
|
||||
qtbot.waitUntil(height_tracks_hint, timeout=1000)
|
||||
heights[width] = arg_box.height()
|
||||
|
||||
# Narrower container -> more wrapped rows -> taller box.
|
||||
assert heights[300] > heights[800]
|
||||
|
||||
|
||||
def test_spinbox_limits_from_scan_info(qtbot):
|
||||
group_input = {
|
||||
"name": "Kwarg Test",
|
||||
|
||||
Reference in New Issue
Block a user