diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 74e0e3fb..3af90274 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -7,6 +7,7 @@ from qtpy.QtWidgets import ( QApplication, QGroupBox, QHBoxLayout, + QPushButton, QSplitter, QTabWidget, QVBoxLayout, @@ -17,6 +18,7 @@ from bec_widgets.utils import BECDispatcher from bec_widgets.utils.colors import apply_theme from bec_widgets.widgets.containers.dock import BECDockArea from bec_widgets.widgets.containers.figure import BECFigure +from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole @@ -50,11 +52,16 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: "d1": self.d1, "d2": self.d2, "wave": self.wf, - # "bar": self.bar, - # "cm": self.colormap, "im": self.im, "mm": self.mm, "mw": self.mw, + "lm": self.lm, + "btn1": self.btn1, + "btn2": self.btn2, + "btn3": self.btn3, + "btn4": self.btn4, + "btn5": self.btn5, + "btn6": self.btn6, } ) @@ -79,11 +86,25 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: second_tab_layout.addWidget(self.figure) tab_widget.addTab(second_tab, "BEC Figure") + third_tab = QWidget() + third_tab_layout = QVBoxLayout(third_tab) + self.lm = LayoutManagerWidget() + third_tab_layout.addWidget(self.lm) + tab_widget.addTab(third_tab, "Layout Manager Widget") + group_box = QGroupBox("Jupyter Console", splitter) group_box_layout = QVBoxLayout(group_box) self.console = BECJupyterConsole(inprocess=True) group_box_layout.addWidget(self.console) + # Some buttons for layout testing + self.btn1 = QPushButton("Button 1") + self.btn2 = QPushButton("Button 2") + self.btn3 = QPushButton("Button 3") + self.btn4 = QPushButton("Button 4") + self.btn5 = QPushButton("Button 5") + self.btn6 = QPushButton("Button 6") + # add stuff to figure self._init_figure() @@ -93,15 +114,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: self.setWindowTitle("Jupyter Console Window") def _init_figure(self): - self.w1 = self.figure.plot( - x_name="samx", - y_name="bpm4i", - # title="Standard Plot with sync device, custom labels - w1", - # x_label="Motor Position", - # y_label="Intensity (A.U.)", - row=0, - col=0, - ) + self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0) self.w1.set( title="Standard Plot with sync device, custom labels - w1", x_label="Motor Position", @@ -169,14 +182,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover: self.wf = self.d2.add_widget("BECFigure", row=0, col=0) self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config) - # self.wf.plot(x_name="samx", y_name="bpm3a") - # self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel") - # self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1) - # self.bar.set_diameter(200) - - # self.d3 = self.dock.add_dock(name="dock_3", position="bottom") - # self.colormap = pg.GradientWidget() - # self.d3.add_widget(self.colormap, row=0, col=0) self.dock.save_state() diff --git a/bec_widgets/widgets/containers/layout_manager/__init__.py b/bec_widgets/widgets/containers/layout_manager/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/containers/layout_manager/layout_manager.py b/bec_widgets/widgets/containers/layout_manager/layout_manager.py new file mode 100644 index 00000000..6475dba2 --- /dev/null +++ b/bec_widgets/widgets/containers/layout_manager/layout_manager.py @@ -0,0 +1,881 @@ +import math +import sys +from typing import Dict, Literal, Optional, Set, Tuple, Union + +from qtpy.QtWidgets import ( + QApplication, + QComboBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QMainWindow, + QMessageBox, + QPushButton, + QSpinBox, + QSplitter, + QVBoxLayout, + QWidget, +) +from typeguard import typechecked + +from bec_widgets.cli.rpc_wigdet_handler import widget_handler + + +class LayoutManagerWidget(QWidget): + """ + A robust layout manager that extends QGridLayout functionality, allowing + users to add/remove widgets, access widgets by coordinates, shift widgets, + and change the layout dynamically with automatic reindexing to keep the grid compact. + + Supports adding widgets via QWidget instances or string identifiers referencing the widget handler. + """ + + def __init__(self, parent=None, auto_reindex=True): + super().__init__(parent) + self.layout = QGridLayout(self) + self.auto_reindex = auto_reindex + + # Mapping from widget to its position (row, col, rowspan, colspan) + self.widget_positions: Dict[QWidget, Tuple[int, int, int, int]] = {} + + # Mapping from (row, col) to widget + self.position_widgets: Dict[Tuple[int, int], QWidget] = {} + + # Keep track of the current position for automatic placement + self.current_row = 0 + self.current_col = 0 + + def add_widget( + self, + widget: QWidget | str, + row: int | None = None, + col: Optional[int] = None, + rowspan: int = 1, + colspan: int = 1, + shift_existing: bool = True, + shift_direction: Literal["down", "up", "left", "right"] = "right", + ) -> QWidget: + """ + Add a widget to the grid with enhanced shifting capabilities. + + Args: + widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler. + row (int, optional): The row to add the widget to. If None, the next available row is used. + col (int, optional): The column to add the widget to. If None, the next available column is used. + rowspan (int): Number of rows the widget spans. Default is 1. + colspan (int): Number of columns the widget spans. Default is 1. + shift_existing (bool): Whether to shift existing widgets if the target position is occupied. Default is True. + shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. Default is "right". + + Returns: + QWidget: The widget that was added. + """ + # Handle widget creation if a BECWidget string identifier is provided + if isinstance(widget, str): + widget = widget_handler.create_widget(widget) + + if row is None: + row = self.current_row + if col is None: + col = self.current_col + + if (row, col) in self.position_widgets: + if shift_existing: + # Attempt to shift the existing widget in the specified direction + self.shift_widgets(direction=shift_direction, start_row=row, start_col=col) + else: + raise ValueError(f"Position ({row}, {col}) is already occupied.") + + # Add the widget to the layout + self.layout.addWidget(widget, row, col, rowspan, colspan) + self.widget_positions[widget] = (row, col, rowspan, colspan) + self.position_widgets[(row, col)] = widget + + # Update current position for automatic placement + self.current_col = col + colspan + self.current_row = max(self.current_row, row) + + if self.auto_reindex: + self.reindex_grid() + + return widget + + def add_widget_relative( + self, + widget: QWidget | str, + reference_widget: QWidget, + position: Literal["left", "right", "top", "bottom"], + rowspan: int = 1, + colspan: int = 1, + shift_existing: bool = True, + shift_direction: Literal["down", "up", "left", "right"] = "right", + ) -> QWidget: + """ + Add a widget relative to an existing widget. + + Args: + widget (QWidget | str): The widget to add. If str, it is used to create a widget via widget_handler. + reference_widget (QWidget): The widget relative to which the new widget will be placed. + position (Literal["left", "right", "top", "bottom"]): Position relative to the reference widget. + rowspan (int): Number of rows the widget spans. Default is 1. + colspan (int): Number of columns the widget spans. Default is 1. + shift_existing (bool): Whether to shift existing widgets if the target position is occupied. + shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. + + Returns: + QWidget: The widget that was added. + + Raises: + ValueError: If the reference widget is not found. + """ + if reference_widget not in self.widget_positions: + raise ValueError("Reference widget not found in layout.") + + ref_row, ref_col, ref_rowspan, ref_colspan = self.widget_positions[reference_widget] + + # Determine new widget position based on the specified relative position + if position == "left": + new_row = ref_row + new_col = ref_col - 1 + elif position == "right": + new_row = ref_row + new_col = ref_col + ref_colspan + elif position == "top": + new_row = ref_row - 1 + new_col = ref_col + elif position == "bottom": + new_row = ref_row + ref_rowspan + new_col = ref_col + else: + raise ValueError("Invalid position. Choose from 'left', 'right', 'top', 'bottom'.") + + # Add the widget at the calculated position + return self.add_widget( + widget=widget, + row=new_row, + col=new_col, + rowspan=rowspan, + colspan=colspan, + shift_existing=shift_existing, + shift_direction=shift_direction, + ) + + def move_widget_by_coords( + self, + current_row: int, + current_col: int, + new_row: int, + new_col: int, + shift: bool = True, + shift_direction: Literal["down", "up", "left", "right"] = "right", + ) -> None: + """ + Move a widget from (current_row, current_col) to (new_row, new_col). + + Args: + current_row (int): Current row of the widget. + current_col (int): Current column of the widget. + new_row (int): Target row. + new_col (int): Target column. + shift (bool): Whether to shift existing widgets if the target position is occupied. + shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. + + Raises: + ValueError: If the widget is not found or target position is invalid. + """ + self.move_widget( + old_row=current_row, + old_col=current_col, + new_row=new_row, + new_col=new_col, + shift=shift, + shift_direction=shift_direction, + ) + + @typechecked + def move_widget_by_object( + self, + widget: QWidget, + new_row: int, + new_col: int, + shift: bool = True, + shift_direction: Literal["down", "up", "left", "right"] = "right", + ) -> None: + """ + Move a widget to a new position using the widget object. + + Args: + widget (QWidget): The widget to move. + new_row (int): Target row. + new_col (int): Target column. + shift (bool): Whether to shift existing widgets if the target position is occupied. + shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. + + Raises: + ValueError: If the widget is not found or target position is invalid. + """ + if widget not in self.widget_positions: + raise ValueError("Widget not found in layout.") + + old_position = self.widget_positions[widget] + old_row, old_col = old_position[0], old_position[1] + + self.move_widget( + old_row=old_row, + old_col=old_col, + new_row=new_row, + new_col=new_col, + shift=shift, + shift_direction=shift_direction, + ) + + @typechecked + def move_widget( + self, + old_row: int | None = None, + old_col: int | None = None, + new_row: int | None = None, + new_col: int | None = None, + shift: bool = True, + shift_direction: Literal["down", "up", "left", "right"] = "right", + ) -> None: + """ + Move a widget to a new position. If the new position is occupied and shift is True, + shift the existing widget to the specified direction. + + Args: + old_row (int, optional): The current row of the widget. + old_col (int, optional): The current column of the widget. + new_row (int, optional): The target row to move the widget to. + new_col (int, optional): The target column to move the widget to. + shift (bool): Whether to shift existing widgets if the target position is occupied. + shift_direction (Literal["down", "up", "left", "right"]): Direction to shift existing widgets. + + Raises: + ValueError: If the widget is not found or target position is invalid. + """ + if new_row is None or new_col is None: + raise ValueError("Must provide both new_row and new_col to move a widget.") + + if old_row is None and old_col is None: + raise ValueError(f"No widget found at position ({old_row}, {old_col}).") + widget = self.get_widget(old_row, old_col) + + if (new_row, new_col) in self.position_widgets: + if not shift: + raise ValueError(f"Position ({new_row}, {new_col}) is already occupied.") + # Shift the existing widget to make space + self.shift_widgets( + direction=shift_direction, + start_row=new_row if shift_direction in ["down", "up"] else 0, + start_col=new_col if shift_direction in ["left", "right"] else 0, + ) + + # Proceed to move the widget + self.layout.removeWidget(widget) + old_position = self.widget_positions.pop(widget) + self.position_widgets.pop((old_position[0], old_position[1])) + + self.layout.addWidget(widget, new_row, new_col, old_position[2], old_position[3]) + self.widget_positions[widget] = (new_row, new_col, old_position[2], old_position[3]) + self.position_widgets[(new_row, new_col)] = widget + + # Update current_row and current_col for automatic placement if needed + self.current_row = max(self.current_row, new_row) + self.current_col = max(self.current_col, new_col + old_position[3]) + + if self.auto_reindex: + self.reindex_grid() + + @typechecked + def shift_widgets( + self, + direction: Literal["down", "up", "left", "right"], + start_row: int = 0, + start_col: int = 0, + ) -> None: + """ + Shift widgets in the grid in the specified direction starting from the given position. + + Args: + direction (Literal["down", "up", "left", "right"]): Direction to shift widgets. + start_row (int): Starting row index. + start_col (int): Starting column index. + + Raises: + ValueError: If shifting causes widgets to go out of grid boundaries. + """ + shifts = [] + positions_to_shift = [(start_row, start_col)] + visited_positions = set() + + while positions_to_shift: + row, col = positions_to_shift.pop(0) + if (row, col) in visited_positions: + continue + visited_positions.add((row, col)) + + widget = self.position_widgets.get((row, col)) + if widget is None: + continue # No widget at this position + + # Compute new position based on the direction + if direction == "down": + new_row = row + 1 + new_col = col + elif direction == "up": + new_row = row - 1 + new_col = col + elif direction == "right": + new_row = row + new_col = col + 1 + elif direction == "left": + new_row = row + new_col = col - 1 + + # Check for negative indices + if new_row < 0 or new_col < 0: + raise ValueError("Shifting widgets out of grid boundaries.") + + # If the new position is occupied, add it to the positions to shift + if (new_row, new_col) in self.position_widgets: + positions_to_shift.append((new_row, new_col)) + + shifts.append( + (widget, (row, col), (new_row, new_col), self.widget_positions[widget][2:]) + ) + + # Remove all widgets from their old positions + for widget, (old_row, old_col), _, _ in shifts: + self.layout.removeWidget(widget) + self.position_widgets.pop((old_row, old_col)) + + # Add widgets to their new positions + for widget, _, (new_row, new_col), (rowspan, colspan) in shifts: + self.layout.addWidget(widget, new_row, new_col, rowspan, colspan) + self.widget_positions[widget] = (new_row, new_col, rowspan, colspan) + self.position_widgets[(new_row, new_col)] = widget + + # Update current_row and current_col if needed + self.current_row = max(self.current_row, new_row) + self.current_col = max(self.current_col, new_col + colspan) + + def shift_all_widgets(self, direction: Literal["down", "up", "left", "right"]) -> None: + """ + Shift all widgets in the grid in the specified direction to make room and prevent negative indices. + + Args: + direction (Literal["down", "up", "left", "right"]): Direction to shift all widgets. + """ + # First, collect all the shifts to perform + shifts = [] + for widget, (row, col, rowspan, colspan) in self.widget_positions.items(): + + if direction == "down": + new_row = row + 1 + new_col = col + elif direction == "up": + new_row = row - 1 + new_col = col + elif direction == "right": + new_row = row + new_col = col + 1 + elif direction == "left": + new_row = row + new_col = col - 1 + + # Check for negative indices + if new_row < 0 or new_col < 0: + raise ValueError("Shifting widgets out of grid boundaries.") + + shifts.append((widget, (row, col), (new_row, new_col), (rowspan, colspan))) + + # Now perform the shifts + for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts: + self.layout.removeWidget(widget) + self.position_widgets.pop((old_row, old_col)) + + for widget, (old_row, old_col), (new_row, new_col), (rowspan, colspan) in shifts: + self.layout.addWidget(widget, new_row, new_col, rowspan, colspan) + self.widget_positions[widget] = (new_row, new_col, rowspan, colspan) + self.position_widgets[(new_row, new_col)] = widget + + # Update current_row and current_col based on new widget positions + self.current_row = max((pos[0] for pos in self.position_widgets.keys()), default=0) + self.current_col = max((pos[1] for pos in self.position_widgets.keys()), default=0) + + def remove( + self, + row: Optional[int] = None, + col: Optional[int] = None, + coordinates: Optional[Tuple[int, int]] = None, + ) -> None: + """ + Remove a widget from the layout. Can be removed by widget ID or by coordinates. + + Args: + row (int, optional): The row coordinate of the widget to remove. + col (int, optional): The column coordinate of the widget to remove. + coordinates (tuple[int, int], optional): The (row, col) coordinates of the widget to remove. + + Raises: + ValueError: If the widget to remove is not found. + """ + if coordinates: + row, col = coordinates + widget = self.get_widget(row, col) + if widget is None: + raise ValueError(f"No widget found at coordinates {coordinates}.") + elif row is not None and col is not None: + widget = self.get_widget(row, col) + if widget is None: + raise ValueError(f"No widget found at position ({row}, {col}).") + else: + raise ValueError( + "Must provide either widget_id, coordinates, or both row and col for removal." + ) + + self.remove_widget(widget) + + def remove_widget(self, widget: QWidget) -> None: + """ + Remove a widget from the grid and reindex the grid to keep it compact. + + Args: + widget (QWidget): The widget to remove. + + Raises: + ValueError: If the widget is not found in the layout. + """ + if widget not in self.widget_positions: + raise ValueError("Widget not found in layout.") + + position = self.widget_positions.pop(widget) + self.position_widgets.pop((position[0], position[1])) + self.layout.removeWidget(widget) + widget.setParent(None) # Remove widget from the parent + widget.deleteLater() + + # Reindex the grid to maintain compactness + if self.auto_reindex: + self.reindex_grid() + + def get_widget(self, row: int, col: int) -> QWidget | None: + """ + Get the widget at the specified position. + + Args: + row (int): The row coordinate. + col (int): The column coordinate. + + Returns: + QWidget | None: The widget at the specified position, or None if empty. + """ + return self.position_widgets.get((row, col)) + + def get_widget_position(self, widget: QWidget) -> Tuple[int, int, int, int] | None: + """ + Get the position of the specified widget. + + Args: + widget (QWidget): The widget to query. + + Returns: + Tuple[int, int, int, int] | None: The (row, col, rowspan, colspan) tuple, or None if not found. + """ + return self.widget_positions.get(widget) + + def change_layout(self, num_rows: int | None = None, num_cols: int | None = None) -> None: + """ + Change the layout to have a certain number of rows and/or columns, + rearranging the widgets accordingly. + + If only one of num_rows or num_cols is provided, the other is calculated automatically + based on the number of widgets and the provided constraint. + + If both are provided, num_rows is calculated based on num_cols. + + Args: + num_rows (int | None): The new maximum number of rows. + num_cols (int | None): The new maximum number of columns. + """ + if num_rows is None and num_cols is None: + return # Nothing to change + + total_widgets = len(self.widget_positions) + + if num_cols is not None: + # Calculate num_rows based on num_cols + num_rows = math.ceil(total_widgets / num_cols) + elif num_rows is not None: + # Calculate num_cols based on num_rows + num_cols = math.ceil(total_widgets / num_rows) + + # Sort widgets by current position (row-major order) + widgets_sorted = sorted( + self.widget_positions.items(), + key=lambda item: (item[1][0], item[1][1]), # Sort by row, then column + ) + + # Clear the layout without deleting widgets + for widget, _ in widgets_sorted: + self.layout.removeWidget(widget) + + # Reset position mappings + self.widget_positions.clear() + self.position_widgets.clear() + + # Re-add widgets based on new layout constraints + current_row, current_col = 0, 0 + for widget, _ in widgets_sorted: + if current_col >= num_cols: + current_col = 0 + current_row += 1 + self.layout.addWidget(widget, current_row, current_col, 1, 1) + self.widget_positions[widget] = (current_row, current_col, 1, 1) + self.position_widgets[(current_row, current_col)] = widget + current_col += 1 + + # Update current_row and current_col for automatic placement + self.current_row = current_row + self.current_col = current_col + + # Reindex the grid to ensure compactness + self.reindex_grid() + + def clear_layout(self) -> None: + """ + Remove all widgets from the layout without deleting them. + """ + for widget in list(self.widget_positions): + self.layout.removeWidget(widget) + self.position_widgets.pop( + (self.widget_positions[widget][0], self.widget_positions[widget][1]) + ) + self.widget_positions.pop(widget) + widget.setParent(None) # Optionally hide/remove the widget + + self.current_row = 0 + self.current_col = 0 + + def reindex_grid(self) -> None: + """ + Reindex the grid to remove empty rows and columns, ensuring that + widget coordinates are contiguous and start from (0, 0). + """ + # Step 1: Collect all occupied positions + occupied_positions = sorted(self.position_widgets.keys()) + + if not occupied_positions: + # No widgets to reindex + self.clear_layout() + return + + # Step 2: Determine the new mapping by eliminating empty columns and rows + # Find unique rows and columns + unique_rows = sorted(set(pos[0] for pos in occupied_positions)) + unique_cols = sorted(set(pos[1] for pos in occupied_positions)) + + # Create mappings from old to new indices + row_mapping = {old_row: new_row for new_row, old_row in enumerate(unique_rows)} + col_mapping = {old_col: new_col for new_col, old_col in enumerate(unique_cols)} + + # Step 3: Collect widgets with their new positions + widgets_with_new_positions = [] + for widget, (row, col, rowspan, colspan) in self.widget_positions.items(): + new_row = row_mapping[row] + new_col = col_mapping[col] + widgets_with_new_positions.append((widget, new_row, new_col, rowspan, colspan)) + + # Step 4: Clear the layout and reset mappings + self.clear_layout() + + # Reset current_row and current_col + self.current_row = 0 + self.current_col = 0 + + # Step 5: Re-add widgets with new positions + for widget, new_row, new_col, rowspan, colspan in widgets_with_new_positions: + self.layout.addWidget(widget, new_row, new_col, rowspan, colspan) + self.widget_positions[widget] = (new_row, new_col, rowspan, colspan) + self.position_widgets[(new_row, new_col)] = widget + + # Update current position for automatic placement + self.current_col = max(self.current_col, new_col + colspan) + self.current_row = max(self.current_row, new_row) + + def get_widgets_positions(self) -> Dict[QWidget, Tuple[int, int, int, int]]: + """ + Get the positions of all widgets in the layout. + + Returns: + Dict[QWidget, Tuple[int, int, int, int]]: Mapping of widgets to their (row, col, rowspan, colspan). + """ + return self.widget_positions.copy() + + def print_all_button_text(self): + """Debug function to print the text of all QPushButton widgets.""" + print("Coordinates - Button Text") + for coord, widget in self.position_widgets.items(): + if isinstance(widget, QPushButton): + print(f"{coord} - {widget.text()}") + + +#################################################################################################### +# The following code is for the GUI control panel to interact with the LayoutManagerWidget. +# It is not covered by any tests as it serves only as an example for the LayoutManagerWidget class. +#################################################################################################### + + +class ControlPanel(QWidget): # pragma: no cover + def __init__(self, layout_manager: LayoutManagerWidget): + super().__init__() + self.layout_manager = layout_manager + self.init_ui() + + def init_ui(self): + main_layout = QVBoxLayout() + + # Add Widget by Coordinates + add_coord_group = QGroupBox("Add Widget by Coordinates") + add_coord_layout = QGridLayout() + + add_coord_layout.addWidget(QLabel("Text:"), 0, 0) + self.text_input = QLineEdit() + add_coord_layout.addWidget(self.text_input, 0, 1) + + add_coord_layout.addWidget(QLabel("Row:"), 1, 0) + self.row_input = QSpinBox() + self.row_input.setMinimum(0) + add_coord_layout.addWidget(self.row_input, 1, 1) + + add_coord_layout.addWidget(QLabel("Column:"), 2, 0) + self.col_input = QSpinBox() + self.col_input.setMinimum(0) + add_coord_layout.addWidget(self.col_input, 2, 1) + + self.add_button = QPushButton("Add at Coordinates") + self.add_button.clicked.connect(self.add_at_coordinates) + add_coord_layout.addWidget(self.add_button, 3, 0, 1, 2) + + add_coord_group.setLayout(add_coord_layout) + main_layout.addWidget(add_coord_group) + + # Add Widget Relative + add_rel_group = QGroupBox("Add Widget Relative to Existing") + add_rel_layout = QGridLayout() + + add_rel_layout.addWidget(QLabel("Text:"), 0, 0) + self.rel_text_input = QLineEdit() + add_rel_layout.addWidget(self.rel_text_input, 0, 1) + + add_rel_layout.addWidget(QLabel("Reference Widget:"), 1, 0) + self.ref_widget_combo = QComboBox() + add_rel_layout.addWidget(self.ref_widget_combo, 1, 1) + + add_rel_layout.addWidget(QLabel("Position:"), 2, 0) + self.position_combo = QComboBox() + self.position_combo.addItems(["left", "right", "top", "bottom"]) + add_rel_layout.addWidget(self.position_combo, 2, 1) + + self.add_rel_button = QPushButton("Add Relative") + self.add_rel_button.clicked.connect(self.add_relative) + add_rel_layout.addWidget(self.add_rel_button, 3, 0, 1, 2) + + add_rel_group.setLayout(add_rel_layout) + main_layout.addWidget(add_rel_group) + + # Remove Widget + remove_group = QGroupBox("Remove Widget") + remove_layout = QGridLayout() + + remove_layout.addWidget(QLabel("Row:"), 0, 0) + self.remove_row_input = QSpinBox() + self.remove_row_input.setMinimum(0) + remove_layout.addWidget(self.remove_row_input, 0, 1) + + remove_layout.addWidget(QLabel("Column:"), 1, 0) + self.remove_col_input = QSpinBox() + self.remove_col_input.setMinimum(0) + remove_layout.addWidget(self.remove_col_input, 1, 1) + + self.remove_button = QPushButton("Remove at Coordinates") + self.remove_button.clicked.connect(self.remove_widget) + remove_layout.addWidget(self.remove_button, 2, 0, 1, 2) + + remove_group.setLayout(remove_layout) + main_layout.addWidget(remove_group) + + # Change Layout + change_layout_group = QGroupBox("Change Layout") + change_layout_layout = QGridLayout() + + change_layout_layout.addWidget(QLabel("Number of Rows:"), 0, 0) + self.change_rows_input = QSpinBox() + self.change_rows_input.setMinimum(1) + self.change_rows_input.setValue(1) # Default value + change_layout_layout.addWidget(self.change_rows_input, 0, 1) + + change_layout_layout.addWidget(QLabel("Number of Columns:"), 1, 0) + self.change_cols_input = QSpinBox() + self.change_cols_input.setMinimum(1) + self.change_cols_input.setValue(1) # Default value + change_layout_layout.addWidget(self.change_cols_input, 1, 1) + + self.change_layout_button = QPushButton("Apply Layout Change") + self.change_layout_button.clicked.connect(self.change_layout) + change_layout_layout.addWidget(self.change_layout_button, 2, 0, 1, 2) + + change_layout_group.setLayout(change_layout_layout) + main_layout.addWidget(change_layout_group) + + # Remove All Widgets + self.clear_all_button = QPushButton("Clear All Widgets") + self.clear_all_button.clicked.connect(self.clear_all_widgets) + main_layout.addWidget(self.clear_all_button) + + # Refresh Reference Widgets and Print Button + self.refresh_button = QPushButton("Refresh Reference Widgets") + self.refresh_button.clicked.connect(self.refresh_references) + self.print_button = QPushButton("Print All Button Text") + self.print_button.clicked.connect(self.layout_manager.print_all_button_text) + main_layout.addWidget(self.refresh_button) + main_layout.addWidget(self.print_button) + + main_layout.addStretch() + self.setLayout(main_layout) + self.refresh_references() + + def refresh_references(self): + self.ref_widget_combo.clear() + widgets = self.layout_manager.get_widgets_positions() + for widget in widgets: + if isinstance(widget, QPushButton): + self.ref_widget_combo.addItem(widget.text(), widget) + + def add_at_coordinates(self): + text = self.text_input.text() + row = self.row_input.value() + col = self.col_input.value() + + if not text: + QMessageBox.warning(self, "Input Error", "Please enter text for the button.") + return + + button = QPushButton(text) + try: + self.layout_manager.add_widget(widget=button, row=row, col=col) + self.refresh_references() + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + + def add_relative(self): + text = self.rel_text_input.text() + ref_index = self.ref_widget_combo.currentIndex() + ref_widget = self.ref_widget_combo.itemData(ref_index) + position = self.position_combo.currentText() + + if not text: + QMessageBox.warning(self, "Input Error", "Please enter text for the button.") + return + + if ref_widget is None: + QMessageBox.warning(self, "Input Error", "Please select a reference widget.") + return + + button = QPushButton(text) + try: + self.layout_manager.add_widget_relative( + widget=button, reference_widget=ref_widget, position=position + ) + self.refresh_references() + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + + def remove_widget(self): + row = self.remove_row_input.value() + col = self.remove_col_input.value() + + try: + widget = self.layout_manager.get_widget(row, col) + if widget is None: + QMessageBox.warning(self, "Not Found", f"No widget found at ({row}, {col}).") + return + self.layout_manager.remove_widget(widget) + self.refresh_references() + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + + def change_layout(self): + num_rows = self.change_rows_input.value() + num_cols = self.change_cols_input.value() + + try: + self.layout_manager.change_layout(num_rows=num_rows, num_cols=num_cols) + self.refresh_references() + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + + def clear_all_widgets(self): + reply = QMessageBox.question( + self, + "Confirm Clear", + "Are you sure you want to remove all widgets?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + + if reply == QMessageBox.Yes: + try: + self.layout_manager.clear_layout() + self.refresh_references() + except Exception as e: + QMessageBox.critical(self, "Error", str(e)) + + +class MainWindow(QMainWindow): # pragma: no cover + def __init__(self): + super().__init__() + self.setWindowTitle("Layout Manager Demo") + self.resize(800, 600) + self.init_ui() + + def init_ui(self): + central_widget = QWidget() + main_layout = QHBoxLayout() + + # Layout Area GroupBox + layout_group = QGroupBox("Layout Area") + layout_group.setMinimumSize(400, 400) + layout_layout = QVBoxLayout() + + self.layout_manager = LayoutManagerWidget() + layout_layout.addWidget(self.layout_manager) + + layout_group.setLayout(layout_layout) + + # Splitter + splitter = QSplitter() + splitter.addWidget(layout_group) + + # Control Panel + control_panel = ControlPanel(self.layout_manager) + control_group = QGroupBox("Control Panel") + control_layout = QVBoxLayout() + control_layout.addWidget(control_panel) + control_layout.addStretch() + control_group.setLayout(control_layout) + splitter.addWidget(control_group) + + main_layout.addWidget(splitter) + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + window = MainWindow() + window.show() + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_layout_manager.py b/tests/unit_tests/test_layout_manager.py new file mode 100644 index 00000000..2a49d7d3 --- /dev/null +++ b/tests/unit_tests/test_layout_manager.py @@ -0,0 +1,368 @@ +from typing import Optional +from unittest.mock import patch + +import pytest +from qtpy.QtWidgets import QLabel, QPushButton, QWidget + +from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget + + +class MockWidgetHandler: + def create_widget(self, widget_type: str) -> Optional[QWidget]: + if widget_type == "ButtonWidget": + return QPushButton() + elif widget_type == "LabelWidget": + return QLabel() + else: + return None + + +@pytest.fixture +def mock_widget_handler(): + handler = MockWidgetHandler() + with patch( + "bec_widgets.widgets.containers.layout_manager.layout_manager.widget_handler", handler + ): + yield handler + + +@pytest.fixture +def layout_manager(qtbot, mock_widget_handler): + widget = LayoutManagerWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_add_widget_empty_position(layout_manager): + """Test adding a widget to an empty position without shifting.""" + btn1 = QPushButton("Button 1") + layout_manager.add_widget(btn1, row=0, col=0) + + assert layout_manager.get_widget(0, 0) == btn1 + assert layout_manager.widget_positions[btn1] == (0, 0, 1, 1) + assert layout_manager.position_widgets[(0, 0)] == btn1 + + +def test_add_widget_occupied_position(layout_manager): + """Test adding a widget to an occupied position with shifting (default direction right).""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=0, col=0) # This should shift btn1 to the right + + assert layout_manager.get_widget(0, 0) == btn2 + assert layout_manager.get_widget(0, 1) == btn1 + assert layout_manager.widget_positions[btn2] == (0, 0, 1, 1) + assert layout_manager.widget_positions[btn1] == (0, 1, 1, 1) + + +def test_add_widget_directional_shift_down(layout_manager): + """Test adding a widget to an occupied position but shifting down instead of right.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + btn3 = QPushButton("Button 3") + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=0, col=0) # Shifts btn1 to the right by default + + # Now add btn3 at (0,1) but shift direction is down, so it should push btn1 down. + layout_manager.add_widget(btn3, row=0, col=1, shift_direction="down") + + assert layout_manager.get_widget(0, 0) == btn2 + assert layout_manager.get_widget(0, 1) == btn3 + assert layout_manager.get_widget(1, 1) == btn1 + + +def test_remove_widget_by_position(layout_manager): + """Test removing a widget by specifying its row and column.""" + btn1 = QPushButton("Button 1") + layout_manager.add_widget(btn1, row=0, col=0) + + layout_manager.remove(row=0, col=0) + + assert layout_manager.get_widget(0, 0) is None + assert btn1 not in layout_manager.widget_positions + + +def test_move_widget_with_shift(layout_manager): + """Test moving a widget to an occupied position, triggering a shift.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + btn3 = QPushButton("Button 3") + + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=0, col=1) + layout_manager.add_widget(btn3, row=1, col=0) + + layout_manager.move_widget(old_row=0, old_col=0, new_row=0, new_col=1, shift_direction="right") + + assert layout_manager.get_widget(0, 1) == btn1 + assert layout_manager.get_widget(0, 2) == btn2 + assert layout_manager.get_widget(1, 0) == btn3 + + +def test_move_widget_without_shift(layout_manager): + """Test moving a widget to an occupied position without shifting.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=0, col=1) + + with pytest.raises(ValueError) as exc_info: + layout_manager.move_widget(old_row=0, old_col=0, new_row=0, new_col=1, shift=False) + + assert "Position (0, 1) is already occupied." in str(exc_info.value) + + +def test_change_layout_num_cols(layout_manager): + """Test changing the layout by specifying only the number of columns.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + btn3 = QPushButton("Button 3") + btn4 = QPushButton("Button 4") + + layout_manager.add_widget(btn1) + layout_manager.add_widget(btn2) + layout_manager.add_widget(btn3) + layout_manager.add_widget(btn4) + + layout_manager.change_layout(num_cols=2) + + assert layout_manager.get_widget(0, 0) == btn1 + assert layout_manager.get_widget(0, 1) == btn2 + assert layout_manager.get_widget(1, 0) == btn3 + assert layout_manager.get_widget(1, 1) == btn4 + + +def test_change_layout_num_rows(layout_manager): + """Test changing the layout by specifying only the number of rows.""" + btn_list = [QPushButton(f"Button {i}") for i in range(1, 7)] + for btn in btn_list: + layout_manager.add_widget(btn) + + layout_manager.change_layout(num_rows=3) + + assert layout_manager.get_widget(0, 0) == btn_list[0] + assert layout_manager.get_widget(0, 1) == btn_list[1] + assert layout_manager.get_widget(1, 0) == btn_list[2] + assert layout_manager.get_widget(1, 1) == btn_list[3] + assert layout_manager.get_widget(2, 0) == btn_list[4] + assert layout_manager.get_widget(2, 1) == btn_list[5] + + +def test_shift_all_widgets(layout_manager): + """Test shifting all widgets down and then up.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=0, col=1) + + # Shift all down + layout_manager.shift_all_widgets(direction="down") + + assert layout_manager.get_widget(1, 0) == btn1 + assert layout_manager.get_widget(1, 1) == btn2 + + # Shift all up + layout_manager.shift_all_widgets(direction="up") + + assert layout_manager.get_widget(0, 0) == btn1 + assert layout_manager.get_widget(0, 1) == btn2 + + +def test_add_widget_auto_position(layout_manager): + """Test adding widgets without specifying row and column.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1) + layout_manager.add_widget(btn2) + + assert layout_manager.get_widget(0, 0) == btn1 + assert layout_manager.get_widget(0, 1) == btn2 + + +def test_clear_layout(layout_manager): + """Test clearing the entire layout.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + layout_manager.add_widget(btn1) + layout_manager.add_widget(btn2) + + layout_manager.clear_layout() + + assert layout_manager.get_widget(0, 0) is None + assert layout_manager.get_widget(0, 1) is None + assert len(layout_manager.widget_positions) == 0 + + +def test_add_widget_with_span(layout_manager): + """Test adding a widget with rowspan and colspan.""" + btn1 = QPushButton("Button 1") + layout_manager.add_widget(btn1, row=0, col=0, rowspan=2, colspan=2) + + assert layout_manager.widget_positions[btn1] == (0, 0, 2, 2) + + +def test_add_widget_overlap_with_span(layout_manager): + """ + Test adding a widget that overlaps with an existing widget's span. + The code will attempt to shift widgets accordingly. + """ + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1, row=0, col=0, rowspan=2, colspan=1) + + layout_manager.add_widget(btn2, row=1, col=1, shift_direction="right") + + assert layout_manager.get_widget(0, 0) == btn1 + assert layout_manager.widget_positions[btn1] == (0, 0, 2, 1) + assert layout_manager.get_widget(1, 1) == btn2 + assert layout_manager.widget_positions[btn2] == (1, 1, 1, 1) + + +@pytest.mark.parametrize( + "position, btn3_coords", + [("left", (1, 0)), ("right", (1, 2)), ("top", (0, 1)), ("bottom", (2, 1))], +) +def test_add_widget_relative(layout_manager, position, btn3_coords): + """Test adding a widget relative to an existing widget using parameterized data.""" + expected_row, expected_col = btn3_coords + + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + btn3 = QPushButton("Button 3") + + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=1, col=1) + + layout_manager.add_widget_relative(btn3, reference_widget=btn2, position=position) + + assert layout_manager.get_widget(0, 0) == btn1 + assert layout_manager.get_widget(1, 1) == btn2 + assert layout_manager.get_widget(expected_row, expected_col) == btn3 + + +def test_add_widget_relative_invalid_position(layout_manager): + """Test adding a widget relative to an existing widget with an invalid position.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1, row=1, col=1) + with pytest.raises(ValueError) as exc_info: + layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="invalid_position") + + assert "Invalid position. Choose from 'left', 'right', 'top', 'bottom'." in str(exc_info.value) + btn2.deleteLater() + + +def test_add_widget_relative_to_nonexistent_widget(layout_manager): + """Test adding a widget relative to a widget that does not exist in the layout.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + with pytest.raises(ValueError) as exc_info: + layout_manager.add_widget_relative(btn2, reference_widget=btn1, position="left") + + assert "Reference widget not found in layout." in str(exc_info.value) + btn1.deleteLater() + btn2.deleteLater() + + +def test_add_widget_relative_with_shift(layout_manager): + """Test adding a widget relative to an existing widget with shifting.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + btn3 = QPushButton("Button 3") + + layout_manager.add_widget(btn1, row=1, col=1) + layout_manager.add_widget(btn2, row=1, col=0) + + layout_manager.add_widget_relative( + btn3, reference_widget=btn1, position="left", shift_direction="right" + ) + + assert layout_manager.get_widget(0, 0) == btn3 + assert layout_manager.get_widget(1, 1) == btn2 + assert layout_manager.get_widget(0, 1) == btn1 + + +def test_move_widget_by_object(layout_manager): + """Test moving a widget using the widget object.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1) + layout_manager.add_widget(btn2, row=0, col=1) + + layout_manager.move_widget_by_object(btn1, new_row=1, new_col=1) + + # the grid is reindex after each move, so the new positions are (0,0) and (1,0), because visually there is only one column + assert layout_manager.get_widget(1, 0) == btn1 + assert layout_manager.get_widget(0, 0) == btn2 + + +def test_move_widget_by_coords(layout_manager): + """Test moving a widget using its current coordinates.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1) + layout_manager.add_widget(btn2, row=0, col=1) + + layout_manager.move_widget_by_coords(0, 0, 1, 0, shift_direction="down") + + assert layout_manager.get_widget(1, 0) == btn1 + assert layout_manager.get_widget(0, 1) == btn2 + + +def test_change_layout_no_arguments(layout_manager): + """Test changing the layout with no arguments (should do nothing).""" + btn1 = QPushButton("Button 1") + layout_manager.add_widget(btn1, row=0, col=0) + + layout_manager.change_layout() + + assert layout_manager.get_widget(0, 0) == btn1 + assert len(layout_manager.widget_positions) == 1 + + +def test_remove_nonexistent_widget(layout_manager): + """Test removing a widget that doesn't exist in the layout.""" + with pytest.raises(ValueError) as exc_info: + layout_manager.remove(row=0, col=0) + + assert "No widget found at position (0, 0)." in str(exc_info.value) + + +def test_reindex_grid_after_removal(layout_manager): + """Test reindexing the grid after removing a widget.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + layout_manager.add_widget(btn1) + layout_manager.add_widget(btn2, row=0, col=1) + + layout_manager.remove_widget(btn1) + layout_manager.reindex_grid() + + # After removal and reindex, btn2 should shift to (0,0) + assert layout_manager.get_widget(0, 0) == btn2 + assert layout_manager.widget_positions[btn2] == (0, 0, 1, 1) + + +def test_shift_all_widgets_up_at_top_row(layout_manager): + """Test shifting all widgets up when they are already at the top row.""" + btn1 = QPushButton("Button 1") + btn2 = QPushButton("Button 2") + + layout_manager.add_widget(btn1, row=0, col=0) + layout_manager.add_widget(btn2, row=0, col=1) + + # Shifting up should cause an error since widgets can't move above row 0 + with pytest.raises(ValueError) as exc_info: + layout_manager.shift_all_widgets(direction="up") + + assert "Shifting widgets out of grid boundaries." in str(exc_info.value)