0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 03:31:50 +02:00

feat(scan_group_box): scan box for args and kwargs separated from ScanControlGUI code

This commit is contained in:
2024-06-19 00:06:37 +02:00
parent ca856384f3
commit d8cf44134c
2 changed files with 224 additions and 457 deletions

View File

@ -2,71 +2,21 @@ import qdarktheme
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QComboBox,
QDoubleSpinBox,
QFrame,
QGridLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLayout,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils import BECConnector
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets import StopButton
# TODO GENERAL
# - extract
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS = "dict"
class ArgLabel(QLabel):
def __init__(self, text: str, parent=None, arg_name: str = None, *args, **kwargs):
super().__init__(text, parent=parent, *args, **kwargs)
self.arg_name = arg_name
class DeviceLineEdit(BECConnector, QLineEdit):
def __init__(self, parent=None, client=None, gui_id: str | None = None):
super().__init__(client=client, gui_id=gui_id)
QLineEdit.__init__(self, parent=parent)
self.get_bec_shortcuts()
def get_device(self):
return getattr(self.dev, self.text().lower())
from bec_widgets.widgets.scan_control.scan_group_box import ScanGroupBox
class ScanControl(BECConnector, QWidget):
WIDGET_HANDLER = {
ScanArgType.DEVICE: DeviceLineEdit,
ScanArgType.DEVICEBASE: DeviceLineEdit,
ScanArgType.FLOAT: QDoubleSpinBox,
ScanArgType.INT: QSpinBox,
ScanArgType.BOOL: QCheckBox,
ScanArgType.STR: QLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
}
def __init__(
self, parent=None, client=None, gui_id: str | None = None, allowed_scans: list | None = None
@ -164,62 +114,20 @@ class ScanControl(BECConnector, QWidget):
self.arg_group = gui_config.get("arg_group", None)
self.kwarg_groups = gui_config.get("kwarg_groups", None)
if self.arg_box is None:
self.button_add_bundle.setEnabled(False)
self.button_remove_bundle.setEnabled(False)
if len(self.arg_group["arg_inputs"]) > 0:
self.add_arg_group(self.arg_group)
self.button_add_bundle.setEnabled(True)
self.button_remove_bundle.setEnabled(True)
self.add_arg_group(self.arg_group) # TODO here class method for arg box
if len(self.kwarg_groups) > 0:
self.add_kwargs_boxes(self.kwarg_groups)
self.update()
self.adjustSize()
def add_input_labels(self, group_inputs: dict, grid_layout: QGridLayout, 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.
grid_layout(QGridLayout): The grid layout to which the arg_group will be added.
"""
for column_index, item in enumerate(group_inputs):
arg_name = item.get("name", None)
display_name = item.get("display_name", arg_name)
tooltip = item.get("tooltip", None)
label = ArgLabel(text=display_name, arg_name=arg_name)
if tooltip is not None:
label.setToolTip(item["tooltip"])
grid_layout.addWidget(label, row, column_index)
def add_input_widgets(self, group_inputs: dict, grid_layout: QGridLayout, row) -> None:
for column_index, item in enumerate(group_inputs):
widget = self.WIDGET_HANDLER.get(item["type"], None)
if widget is None:
print(f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'")
continue
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999)
# if item["default"] != "_empty": # TODO fix for comboboxes and spinboxes
# WidgetIO.set_value(widget, item["default"])
# widget.setValue(item["default"])
grid_layout.addWidget(widget(), row, column_index)
def add_input_box(self, group: dict, rows: int = 1):
"""
Adds the given gui_group to the scan control layout.
Args:
group(dict): Dictionary containing the gui_group information.
rows(int): Number of input rows to add to the layout.
"""
input_box = QGroupBox(group["name"])
group_layout = QGridLayout(input_box)
self.add_input_labels(group["inputs"], group_layout, 0)
for i in range(rows):
self.add_input_widgets(group["inputs"], group_layout, i + 1)
input_box.setLayout(group_layout)
self.layout.addWidget(input_box)
input_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
return input_box
def add_kwargs_boxes(self, groups: list):
"""
Adds the given gui_groups to the scan control layout.
@ -228,7 +136,8 @@ class ScanControl(BECConnector, QWidget):
groups(list): List of dictionaries containing the gui_group information.
"""
for group in groups:
box = self.add_input_box(group)
box = ScanGroupBox(box_type="kwargs", config=group)
box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(box)
self.kwarg_boxes.append(box)
@ -238,37 +147,15 @@ class ScanControl(BECConnector, QWidget):
Args:
"""
self.arg_box = self.add_input_box(group, rows=group["min"])
self.real_arg_box_row_count = group["min"]
self.arg_box = ScanGroupBox(box_type="args", config=group)
self.arg_box.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
self.layout.addWidget(self.arg_box)
def add_arg_bundle(self):
if self.arg_box is not None:
current_row_count = self.arg_box.layout().rowCount()
if self.arg_group["max"] is not None and current_row_count >= self.arg_group["max"]:
return
self.add_input_widgets(
self.arg_group["inputs"], self.arg_box.layout(), self.arg_box.layout().rowCount()
)
self.real_arg_box_row_count += 1
print(f"row count REAL: {self.real_arg_box_row_count}")
print(f"row count QT: {self.arg_box.layout().rowCount()}")
self.arg_box.add_widget_bundle()
def remove_arg_bundle(self):
if self.arg_box is not None:
current_row_count = self.real_arg_box_row_count # self.arg_box.layout().rowCount()
layout = self.arg_box.layout()
if current_row_count > self.arg_group["min"]:
for i in range(layout.columnCount()):
widget = layout.itemAtPosition(current_row_count, i).widget()
layout.removeWidget(widget)
widget.deleteLater()
self.real_arg_box_row_count -= 1
print(f"row count REAL: {self.real_arg_box_row_count}")
print(f"row count QT: {self.arg_box.layout().rowCount()}")
# self.arg_box.layout().removeRow(current_row_count - 1)
self.arg_box.remove_widget_bundle()
def reset_layout(self):
"""Clears the scan control layout from GuiGroups and ArgGroups boxes."""
@ -284,343 +171,15 @@ class ScanControl(BECConnector, QWidget):
box.deleteLater()
self.kwarg_boxes = []
def add_labels_to_table(
self, labels: list, table: QTableWidget
) -> None: # TODO could be moved to BECTable -> not needed
"""
Adds labels to the given table widget as a header row.
Args:
labels(list): List of label names to add.
table(QTableWidget): The table widget to which labels will be added.
"""
table.setColumnCount(len(labels))
table.setHorizontalHeaderLabels(labels)
def generate_args_input_fields(
self, scan_info: dict
) -> None: # TODO decide how to deal with arg bundles
"""
Generates input fields for args.
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
# Setup args table limits
self.set_args_table_limits(self.args_table, scan_info)
# Get arg_input from selected scan
self.arg_input = scan_info.get("arg_input", {})
# Generate labels for table
self.add_labels_to_table(list(self.arg_input.keys()), self.args_table)
# add minimum number of args rows
if self.arg_size_min is not None:
for i in range(self.arg_size_min):
self.add_bundle()
def generate_kwargs_input_fields(
self, scan_info: dict, inputs: dict = {}, hidden: list = []
) -> None: # TODO can be removed
"""
Generates input fields for kwargs
Args:
scan_info(dict): Scan signature dictionary from BEC.
"""
# Create a new kwarg layout to replace the old one - this is necessary because otherwise row count is not reseted
self.clear_and_delete_layout(self.kwargs_layout)
self.kwargs_layout = self.create_new_grid_layout() # Create new grid layout
self.scan_control_layout.insertLayout(0, self.kwargs_layout)
# Get signature
signature = scan_info.get("signature", [])
# Extract kwargs from the converted signature
parameters = [
param
for param in signature
if param["annotation"] != "_empty" and param["name"] not in hidden
]
# Add labels
self.add_labels_to_layout(parameters, self.kwargs_layout)
# Add widgets
widgets = self.generate_widgets_from_signature(parameters)
self.add_widgets_row_to_layout(self.kwargs_layout, widgets)
def create_widget_group(self, title: str, widgets: list) -> QGroupBox: # TODO to be removed
"""
Creates a group box containing the given widgets.
Args:
title(str): Title of the group box.
widgets(list): List of widgets to add to the group box.
Returns:
QGroupBox: Group box containing the given widgets.
"""
group_box = QGroupBox(title)
group_layout = QVBoxLayout(group_box)
for widget in widgets:
group_layout.addWidget(widget)
group_box.setLayout(group_layout)
return group_box
def generate_widgets_from_signature(self, parameters: list) -> list: # TODO to be removed
"""
Generates widgets from the given list of items.
Args:
parameters(list): List of items to create widgets for.
Returns:
list: List of widgets created from the given items.
"""
widgets = [] # Initialize an empty list to hold the widgets
item_default = None
item_type = "_empty"
item_name = "name"
for item in parameters:
if isinstance(item, dict):
item_type = item.get("annotation", "_empty")
item_name = item
item_default = item.get("default", 0)
item_default = item_default if item_default is not None else 0
elif isinstance(item, tuple):
item_name, item_type = item
else:
raise ValueError(f"Unsupported item type '{type(item)}' for parameter '{item}'")
widget_class = self.WIDGET_HANDLER.get(item_type, None)
if widget_class is None:
print(f"Unsupported annotation '{item_type}' for parameter '{item_name}'")
continue
# Instantiate the widget and set some properties if necessary
widget = widget_class()
# set high default range for spin boxes #TODO can be linked to motor/device limits from BEC
if isinstance(widget, (QSpinBox, QDoubleSpinBox)):
widget.setRange(-9999, 9999)
# if item_default is not None:
# WidgetIO.set_value(widget, item_default)
# Add the widget to the list
widgets.append(widget)
return widgets
def set_args_table_limits(
self, table: QTableWidget, scan_info: dict
) -> None: # TODO can be removed
# Get bundle info
arg_bundle_size = scan_info.get("arg_bundle_size", {})
self.arg_size_min = arg_bundle_size.get("min", 1)
self.arg_size_max = arg_bundle_size.get("max", None)
# Clear the previous input fields
table.setRowCount(0) # Wipe table
def add_widgets_row_to_layout(
self, grid_layout: QGridLayout, widgets: list, row_index: int = None
) -> None: # TODO to be removed
"""
Adds a row of widgets to the given grid layout.
Args:
grid_layout (QGridLayout): The grid layout to which widgets will be added.
items (list): List of parameter names to create widgets for.
row_index (int): The row index where the widgets should be added.
"""
# If row_index is not specified, add to the next available row
if row_index is None:
row_index = grid_layout.rowCount()
for column_index, widget in enumerate(widgets):
# Add the widget to the grid layout at the specified row and column
grid_layout.addWidget(widget, row_index, column_index)
def add_widgets_row_to_table(
self, table_widget: QTableWidget, widgets: list, row_index: int = None
) -> None: # TODO to be removed
"""
Adds a row of widgets to the given QTableWidget.
Args:
table_widget (QTableWidget): The table widget to which widgets will be added.
widgets (list): List of widgets to add to the table.
row_index (int): The row index where the widgets should be added. If None, add to the end.
"""
# If row_index is not specified, add to the end of the table
if row_index is None or row_index > table_widget.rowCount():
row_index = table_widget.rowCount()
if self.arg_size_max is not None: # ensure the max args size is not exceeded
if row_index >= self.arg_size_max:
return
table_widget.insertRow(row_index)
for column_index, widget in enumerate(widgets):
# If the widget is a subclass of QWidget, use setCellWidget
if issubclass(type(widget), QWidget):
table_widget.setCellWidget(row_index, column_index, widget)
else:
# Otherwise, assume it's a string or some other value that should be displayed as text
item = QTableWidgetItem(str(widget))
table_widget.setItem(row_index, column_index, item)
# Optionally, adjust the row height based on the content #TODO decide if needed
table_widget.setRowHeight(
row_index,
max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)),
)
def remove_last_row_from_table(self, table_widget: QTableWidget) -> None: # TODO to be removed
"""
Removes the last row from the given QTableWidget until only one row is left.
Args:
table_widget (QTableWidget): The table widget from which the last row will be removed.
"""
row_count = table_widget.rowCount()
if (
row_count > self.arg_size_min
): # Check to ensure there is a minimum number of rows remaining
table_widget.removeRow(row_count - 1)
def create_new_grid_layout(self): # TODO to be removed
new_layout = QGridLayout()
# TODO maybe setup other layouts properties here?
return new_layout
def clear_and_delete_layout(self, layout: QLayout): # TODO can be removed
"""
Clears and deletes the given layout and all its child widgets.
Args:
layout(QLayout): Layout to clear and delete
"""
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget:
widget.deleteLater()
else:
sub_layout = item.layout()
if sub_layout:
self.clear_and_delete_layout(sub_layout)
layout.deleteLater()
def add_bundle(self) -> None: # TODO can be removed
"""Adds a new bundle to the scan control layout"""
# Get widgets used for particular scan and save them to be able to use for adding bundles
args_widgets = self.generate_widgets_from_signature(
self.arg_input.items()
) # TODO decide if make sense to put widget list into method parameters
# Add first widgets row to the table
self.add_widgets_row_to_table(self.args_table, args_widgets)
def remove_bundle(self) -> None: # TODO can be removed
"""Removes the last bundle from the scan control layout"""
self.remove_last_row_from_table(self.args_table)
def extract_kwargs_from_grid_row(self, grid_layout: QGridLayout, row: int) -> dict:
kwargs = {}
for column in range(grid_layout.columnCount()):
label_item = grid_layout.itemAtPosition(row, column)
if label_item is not None:
label_widget = label_item.widget()
if isinstance(label_widget, QLabel):
key = label_widget.text()
# The corresponding value widget is in the next row
value_item = grid_layout.itemAtPosition(row + 1, column)
if value_item is not None:
value_widget = value_item.widget()
# Use WidgetIO.get_value to extract the value
value = WidgetIO.get_value(value_widget)
kwargs[key] = value
return kwargs
def extract_args_from_table(self, table: QTableWidget) -> list:
"""
Extracts the arguments from the given table widget.
Args:
table(QTableWidget): Table widget from which to extract the arguments
"""
args = []
for row in range(table.rowCount()):
row_args = []
for column in range(table.columnCount()):
widget = table.cellWidget(row, column)
if not widget:
continue
if isinstance(widget, QLineEdit): # special case for QLineEdit for Devices
value = widget.text().lower()
if value in self.dev:
value = getattr(self.dev, value)
else:
raise ValueError(f"The device '{value}' is not recognized.")
else:
value = WidgetIO.get_value(widget)
row_args.append(value)
args.extend(row_args)
return args
def extract_kwargs(self, box: QGroupBox) -> dict:
"""
Extracts the parameters from the given group box.
Args:
box(QGroupBox): Group box from which to extract the parameters.
Returns:
dict: Dictionary containing the extracted parameters.
"""
parameters = {}
keys = [label.arg_name for label in box.findChildren(ArgLabel)]
layout = box.layout()
for i in range(layout.columnCount()):
key = keys[i]
widget = layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
parameters[key] = value
return parameters
def extract_args(self, box):
args = []
layout = box.layout()
for i in range(layout.columnCount()):
widget = layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
args.append(value)
return args
def run_scan(self):
args = []
kwargs = {}
if self.arg_box is not None:
args = self.extract_args(self.arg_box)
args = self.arg_box.get_parameters()
for box in self.kwarg_boxes:
box_kwargs = self.extract_kwargs(box)
box_kwargs = box.get_parameters()
kwargs.update(box_kwargs)
print(kwargs)
scan_function = getattr(self.scans, self.comboBox_scan_selection.currentText())
print(f"called with args: {args} and kwargs: {kwargs}")
if callable(scan_function):
scan_function(*args, **kwargs)

View File

@ -0,0 +1,208 @@
from typing import Literal
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QDoubleSpinBox,
QGridLayout,
QLabel,
QLineEdit,
QSpinBox,
)
from qtpy.QtWidgets import QGroupBox
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets import DeviceLineEdit
class ScanArgType:
DEVICE = "device"
FLOAT = "float"
INT = "int"
BOOL = "bool"
STR = "str"
DEVICEBASE = "DeviceBase"
LITERALS = "dict"
class ScanSpinBox(QSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: int | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.setRange(-9999, 9999)
if default is not None:
self.setValue(default)
class ScanDoubleSpinBox(QDoubleSpinBox):
def __init__(
self, parent=None, arg_name: str = None, default: float | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
self.setRange(-9999, 9999)
if default is not None:
self.setValue(default)
class ScanLineEdit(QLineEdit):
def __init__(
self, parent=None, arg_name: str = None, default: str | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
if default is not None:
self.setText(default)
class ScanCheckBox(QCheckBox):
def __init__(
self, parent=None, arg_name: str = None, default: bool | None = None, *args, **kwargs
):
super().__init__(parent=parent, *args, **kwargs)
self.arg_name = arg_name
if default is not None:
self.setChecked(default)
class ScanGroupBox(QGroupBox):
WIDGET_HANDLER = {
ScanArgType.DEVICE: DeviceLineEdit,
ScanArgType.DEVICEBASE: DeviceLineEdit,
ScanArgType.FLOAT: ScanDoubleSpinBox,
ScanArgType.INT: ScanSpinBox,
ScanArgType.BOOL: ScanCheckBox,
ScanArgType.STR: ScanLineEdit,
ScanArgType.LITERALS: QComboBox, # TODO figure out combobox logic
}
def __init__(
self,
parent=None,
box_type=Literal["args", "kwargs"],
config: dict | None = None,
*args,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
self.config = config
self.box_type = box_type
self.layout = QGridLayout(self)
self.labels = []
self.widgets = []
self.init_box(self.config)
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
self.add_input_widgets(self.inputs, 1)
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)
def add_input_widgets(self, group_inputs: dict, row) -> 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):
arg_name = item.get("name", None)
default = item.get("default", None)
widget = self.WIDGET_HANDLER.get(item["type"], None)
if widget is None:
print(f"Unsupported annotation '{item['type']}' for parameter '{item['name']}'")
continue
if default == "_empty":
default = None
widget_to_add = widget(arg_name=arg_name, default=default)
tooltip = item.get("tooltip", None)
if tooltip is not None:
widget_to_add.setToolTip(item["tooltip"])
self.layout.addWidget(widget_to_add, row, column_index)
self.widgets.append(widget_to_add)
def add_widget_bundle(self):
"""
Adds a new row of widgets to the scan control layout. Only usable for arg_groups.
"""
if self.box_type != "args":
return
arg_max = self.config.get("max", None)
row = self.layout.rowCount()
if arg_max is not None and row >= arg_max:
return
self.add_input_widgets(self.inputs, row)
def remove_widget_bundle(self):
"""
Removes the last row of widgets from the scan control layout. Only usable for arg_groups.
"""
if self.box_type != "args":
return
arg_min = self.config.get("min", None)
row = self.layout.rowCount()
if arg_min is not None and row <= arg_min + 1:
return
for widget in self.widgets[-len(self.inputs) :]:
widget.deleteLater()
self.widgets = self.widgets[: -len(self.inputs)]
def get_parameters(self):
"""
Returns the parameters from the widgets in the scan control layout formated to run scan from BEC.
"""
if self.box_type == "args":
print(self._get_arg_parameterts())
return self._get_arg_parameterts()
elif self.box_type == "kwargs":
print(self._get_kwarg_parameters())
return self._get_kwarg_parameters()
def _get_arg_parameterts(self):
args = []
for i in range(1, self.layout.rowCount()):
for j in range(self.layout.columnCount()):
widget = self.layout.itemAtPosition(i, j).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
args.append(value)
return args
def _get_kwarg_parameters(self):
kwargs = {}
for i in range(self.layout.columnCount()):
widget = self.layout.itemAtPosition(1, i).widget()
if isinstance(widget, DeviceLineEdit):
value = widget.get_device()
else:
value = WidgetIO.get_value(widget)
kwargs[widget.arg_name] = value
return kwargs