diff --git a/bec_widgets/widgets/scan_control/scan_control.py b/bec_widgets/widgets/scan_control/scan_control.py index f77c37a0..4e251f97 100644 --- a/bec_widgets/widgets/scan_control/scan_control.py +++ b/bec_widgets/widgets/scan_control/scan_control.py @@ -1,3 +1,4 @@ +import qdarktheme from bec_lib.endpoints import MessageEndpoints from qtpy.QtWidgets import ( QApplication, @@ -13,6 +14,7 @@ from qtpy.QtWidgets import ( QLayout, QLineEdit, QPushButton, + QSizePolicy, QSpinBox, QTableWidget, QTableWidgetItem, @@ -20,8 +22,12 @@ from qtpy.QtWidgets import ( QWidget, ) -from bec_widgets.utils.bec_dispatcher import BECDispatcher +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: @@ -30,24 +36,52 @@ class ScanArgType: INT = "int" BOOL = "bool" STR = "str" + DEVICEBASE = "DeviceBase" + LITERALS = "dict" -class ScanControl(QWidget): +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()) + + +class ScanControl(BECConnector, QWidget): WIDGET_HANDLER = { - ScanArgType.DEVICE: QLineEdit, + 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, allowed_scans=None): - super().__init__(parent) + def __init__( + self, parent=None, client=None, gui_id: str | None = None, allowed_scans: list | None = None + ): + super().__init__(client=client, gui_id=gui_id) + QWidget.__init__(self, parent=parent) # Client from BEC + shortcuts to device manager and scans - self.client = BECDispatcher().client if client is None else client - self.dev = self.client.device_manager.devices - self.scans = self.client.scans + self.get_bec_shortcuts() + + # Main layout + self.layout = QVBoxLayout(self) + self.arg_box = None + self.kwarg_boxes = [] + self.expert_mode = False # TODO implement in the future versions # Scan list - allowed scans for the GUI self.allowed_scans = allowed_scans @@ -56,69 +90,55 @@ class ScanControl(QWidget): self._init_UI() def _init_UI(self): - self.verticalLayout = QVBoxLayout(self) + """ + Initializes the UI of the scan control widget. Create the top box for scan selection and populate scans to main combobox. + """ # Scan selection group box - self.scan_selection_group = QGroupBox("Scan Selection", self) - self.scan_selection_layout = QVBoxLayout(self.scan_selection_group) - self.comboBox_scan_selection = QComboBox(self.scan_selection_group) - self.button_run_scan = QPushButton("Run Scan", self.scan_selection_group) - self.scan_selection_layout.addWidget(self.comboBox_scan_selection) - self.scan_selection_layout.addWidget(self.button_run_scan) - self.verticalLayout.addWidget(self.scan_selection_group) - - # Scan control group box - self.scan_control_group = QGroupBox("Scan Control", self) - self.scan_control_layout = QVBoxLayout(self.scan_control_group) - self.verticalLayout.addWidget(self.scan_control_group) - - # Kwargs layout - just placeholder - self.kwargs_layout = QGridLayout() - self.scan_control_layout.addLayout(self.kwargs_layout) - - # 1st Separator - self.add_horizontal_separator(self.scan_control_layout) - - # Buttons - self.button_layout = QHBoxLayout() - self.pushButton_add_bundle = QPushButton("Add Bundle", self.scan_control_group) - self.pushButton_add_bundle.clicked.connect(self.add_bundle) - self.pushButton_remove_bundle = QPushButton("Remove Bundle", self.scan_control_group) - self.pushButton_remove_bundle.clicked.connect(self.remove_bundle) - self.button_layout.addWidget(self.pushButton_add_bundle) - self.button_layout.addWidget(self.pushButton_remove_bundle) - self.scan_control_layout.addLayout(self.button_layout) - - # 2nd Separator - self.add_horizontal_separator(self.scan_control_layout) - - # Initialize the QTableWidget for args - self.args_table = QTableWidget() - self.args_table.verticalHeader().setSectionResizeMode(QHeaderView.Fixed) - - self.scan_control_layout.addWidget(self.args_table) + self.scan_selection_group = self.create_scan_selection_group() + self.scan_selection_group.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.layout.addWidget(self.scan_selection_group) # Connect signals self.comboBox_scan_selection.currentIndexChanged.connect(self.on_scan_selected) self.button_run_scan.clicked.connect(self.run_scan) + self.button_add_bundle.clicked.connect(self.add_arg_bundle) + self.button_remove_bundle.clicked.connect(self.remove_arg_bundle) # Initialize scan selection self.populate_scans() - def add_horizontal_separator(self, layout) -> None: + def create_scan_selection_group(self) -> QGroupBox: """ - Adds a horizontal separator to the given layout + Creates the scan selection group box with combobox to select the scan and start/stop button. - Args: - layout: Layout to add the separator to + Returns: + QGroupBox: Group box containing the scan selection widgets. """ - separator = QFrame(self.scan_control_group) - separator.setFrameShape(QFrame.HLine) - separator.setFrameShadow(QFrame.Sunken) - layout.addWidget(separator) + + scan_selection_group = QGroupBox("Scan Selection", self) + self.scan_selection_layout = QGridLayout(scan_selection_group) + self.comboBox_scan_selection = QComboBox(scan_selection_group) + # Run button + self.button_run_scan = QPushButton("Start", scan_selection_group) + self.button_run_scan.setStyleSheet("background-color: #559900; color: white") + # Stop button + self.button_stop_scan = StopButton(parent=scan_selection_group) + # Add bundle button + self.button_add_bundle = QPushButton("Add Bundle", scan_selection_group) + # Remove bundle button + self.button_remove_bundle = QPushButton("Remove Bundle", scan_selection_group) + + self.scan_selection_layout.addWidget(self.comboBox_scan_selection, 0, 0, 1, 2) + self.scan_selection_layout.addWidget(self.button_run_scan, 1, 0) + self.scan_selection_layout.addWidget(self.button_stop_scan, 1, 1) + self.scan_selection_layout.addWidget(self.button_add_bundle, 2, 0) + self.scan_selection_layout.addWidget(self.button_remove_bundle, 2, 1) + + return scan_selection_group def populate_scans(self): - """Populates the scan selection combo box with available scans""" + """Populates the scan selection combo box with available scans from BEC session.""" self.available_scans = self.client.connector.get( MessageEndpoints.available_scans() ).resource @@ -132,38 +152,141 @@ class ScanControl(QWidget): else: allowed_scans = self.allowed_scans - # TODO check parent class is ScanBase -> filter out the scans not relevant for GUI self.comboBox_scan_selection.addItems(allowed_scans) def on_scan_selected(self): """Callback for scan selection combo box""" + self.reset_layout() selected_scan_name = self.comboBox_scan_selection.currentText() selected_scan_info = self.available_scans.get(selected_scan_name, {}) - print(selected_scan_info) # TODO remove when widget will be more mature - # Generate kwargs input - self.generate_kwargs_input_fields(selected_scan_info) + gui_config = selected_scan_info.get("gui_config", {}) + self.arg_group = gui_config.get("arg_group", None) + self.kwarg_groups = gui_config.get("kwarg_groups", None) - # Args section - self.generate_args_input_fields(selected_scan_info) + if len(self.arg_group["arg_inputs"]) > 0: + self.add_arg_group(self.arg_group) + if len(self.kwarg_groups) > 0: + self.add_kwargs_boxes(self.kwarg_groups) - def add_labels_to_layout(self, labels: list, grid_layout: QGridLayout) -> None: + self.update() + self.adjustSize() + + def add_input_labels(self, group_inputs: dict, grid_layout: QGridLayout, row: int) -> None: """ - Adds labels to the given grid layout as a separate row. + Adds the given arg_group from arg_bundle to the scan control layout. The input labels are always added to the first row. Args: - labels (list): List of label names to add. - grid_layout (QGridLayout): The grid layout to which labels will be added. + group(dict): Dictionary containing the arg_group information. + grid_layout(QGridLayout): The grid layout to which the arg_group will be added. """ - row_index = grid_layout.rowCount() # Get the next available row - for column_index, label_name in enumerate(labels): - label = QLabel(label_name["name"].capitalize(), self.scan_control_group) - # Add the label to the grid layout at the calculated row and current column - grid_layout.addWidget(label, row_index, column_index) + 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. + + Args: + groups(list): List of dictionaries containing the gui_group information. + """ + for group in groups: + box = self.add_input_box(group) + self.layout.addWidget(box) + self.kwarg_boxes.append(box) + + def add_arg_group(self, group: dict): + """ + Adds the given gui_groups to the scan control layout. + + Args: + """ + self.arg_box = self.add_input_box(group, rows=group["min"]) + self.real_arg_box_row_count = group["min"] + 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()}") + + 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) + + def reset_layout(self): + """Clears the scan control layout from GuiGroups and ArgGroups boxes.""" + if self.arg_box is not None: + self.layout.removeWidget(self.arg_box) + self.arg_box = None + if self.kwarg_boxes != []: + self.remove_kwarg_boxes() + + def remove_kwarg_boxes(self): + for box in self.kwarg_boxes: + self.layout.removeWidget(box) + box.deleteLater() + self.kwarg_boxes = [] def add_labels_to_table( self, labels: list, table: QTableWidget - ) -> None: # TODO could be moved to BECTable + ) -> None: # TODO could be moved to BECTable -> not needed """ Adds labels to the given table widget as a header row. @@ -174,7 +297,9 @@ class ScanControl(QWidget): table.setColumnCount(len(labels)) table.setHorizontalHeaderLabels(labels) - def generate_args_input_fields(self, scan_info: dict) -> None: + def generate_args_input_fields( + self, scan_info: dict + ) -> None: # TODO decide how to deal with arg bundles """ Generates input fields for args. @@ -196,7 +321,9 @@ class ScanControl(QWidget): for i in range(self.arg_size_min): self.add_bundle() - def generate_kwargs_input_fields(self, scan_info: dict) -> None: + def generate_kwargs_input_fields( + self, scan_info: dict, inputs: dict = {}, hidden: list = [] + ) -> None: # TODO can be removed """ Generates input fields for kwargs @@ -212,7 +339,11 @@ class ScanControl(QWidget): signature = scan_info.get("signature", []) # Extract kwargs from the converted signature - parameters = [param for param in signature if param["annotation"] != "_empty"] + 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) @@ -222,7 +353,25 @@ class ScanControl(QWidget): self.add_widgets_row_to_layout(self.kwargs_layout, widgets) - def generate_widgets_from_signature(self, parameters: list) -> list: + 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. @@ -259,15 +408,17 @@ class ScanControl(QWidget): # 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) + # 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: + 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) @@ -278,7 +429,7 @@ class ScanControl(QWidget): def add_widgets_row_to_layout( self, grid_layout: QGridLayout, widgets: list, row_index: int = None - ) -> None: + ) -> None: # TODO to be removed """ Adds a row of widgets to the given grid layout. @@ -297,7 +448,7 @@ class ScanControl(QWidget): def add_widgets_row_to_table( self, table_widget: QTableWidget, widgets: list, row_index: int = None - ) -> None: + ) -> None: # TODO to be removed """ Adds a row of widgets to the given QTableWidget. @@ -329,7 +480,7 @@ class ScanControl(QWidget): max(widget.sizeHint().height() for widget in widgets if isinstance(widget, QWidget)), ) - def remove_last_row_from_table(self, table_widget: QTableWidget) -> None: + 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. @@ -342,12 +493,12 @@ class ScanControl(QWidget): ): # Check to ensure there is a minimum number of rows remaining table_widget.removeRow(row_count - 1) - def create_new_grid_layout(self): + 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): + def clear_and_delete_layout(self, layout: QLayout): # TODO can be removed """ Clears and deletes the given layout and all its child widgets. @@ -366,7 +517,7 @@ class ScanControl(QWidget): self.clear_and_delete_layout(sub_layout) layout.deleteLater() - def add_bundle(self) -> None: + 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( @@ -376,7 +527,7 @@ class ScanControl(QWidget): # Add first widgets row to the table self.add_widgets_row_to_table(self.args_table, args_widgets) - def remove_bundle(self) -> None: + 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) @@ -424,35 +575,65 @@ class ScanControl(QWidget): 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): - # Extract kwargs for the scan - kwargs = { - k.lower(): v - for k, v in self.extract_kwargs_from_grid_row(self.kwargs_layout, 1).items() - } - - # Extract args from the table - args = self.extract_args_from_table(self.args_table) - - # Convert args to lowercase if they are strings - args = [arg.lower() if isinstance(arg, str) else arg for arg in args] - - # Execute the scan + args = [] + kwargs = {} + if self.arg_box is not None: + args = self.extract_args(self.arg_box) + for box in self.kwarg_boxes: + box_kwargs = self.extract_kwargs(box) + 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) + def close(self): + super().close() + # Application example if __name__ == "__main__": # pragma: no cover - # BECclient global variables - client = BECDispatcher().client - client.start() - app = QApplication([]) - scan_control = ScanControl(client=client, allowed_scans=["line_scan", "grid_scan"]) + scan_control = ScanControl(allowed_scans=["fermat_scan", "round_scan", "line_scan"]) + qdarktheme.setup_theme("auto") window = scan_control window.show() app.exec()