feat(flow_layout): new flow layout added; implementation to ScanControl widget

This commit is contained in:
2026-07-08 16:59:07 +02:00
committed by Jan Wyzula
parent a0ac735bf1
commit 1c05a17d5f
5 changed files with 511 additions and 157 deletions
+275
View File
@@ -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
+18 -13
View File
@@ -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:
+95 -42
View File
@@ -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",