diff --git a/bec_widgets/utils/flow_layout.py b/bec_widgets/utils/flow_layout.py new file mode 100644 index 00000000..c3836d59 --- /dev/null +++ b/bec_widgets/utils/flow_layout.py @@ -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() diff --git a/bec_widgets/widgets/control/scan_control/scan_control.py b/bec_widgets/widgets/control/scan_control/scan_control.py index 37ea2440..4adcbdec 100644 --- a/bec_widgets/widgets/control/scan_control/scan_control.py +++ b/bec_widgets/widgets/control/scan_control/scan_control.py @@ -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) diff --git a/bec_widgets/widgets/control/scan_control/scan_group_box.py b/bec_widgets/widgets/control/scan_control/scan_group_box.py index 840b3063..85ee0031 100644 --- a/bec_widgets/widgets/control/scan_control/scan_group_box.py +++ b/bec_widgets/widgets/control/scan_control/scan_group_box.py @@ -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 diff --git a/tests/unit_tests/test_scan_control.py b/tests/unit_tests/test_scan_control.py index 91947e84..a5991922 100644 --- a/tests/unit_tests/test_scan_control.py +++ b/tests/unit_tests/test_scan_control.py @@ -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: diff --git a/tests/unit_tests/test_scan_control_group_box.py b/tests/unit_tests/test_scan_control_group_box.py index e47fb3b0..e483c2e5 100644 --- a/tests/unit_tests/test_scan_control_group_box.py +++ b/tests/unit_tests/test_scan_control_group_box.py @@ -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",