diff --git a/CHANGELOG.md b/CHANGELOG.md index 204a81675..2c6c77437 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added `TextEdit` to handle `name` change. +* Added `DefaultLayout` to handle gerneral `layout` setting to minimal. +* Added `ColorDialog` to manage color dialog. +* Added `SettingLayout` to manage complex layout with config input. +* Added `robot.py` example. + ### Changed ### Removed @@ -22,12 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `list[float]` to accepted types for `Camera.position` and `Camera.target`. * Added `unit` to `Viewer` and `Config`. * Added `bounding_box` and `_update_bounding_box` to `BufferObject`. -* Added `robot.py` example. ### Changed * Fixed `opacity` bug with `BufferObject`. * Updated `SceneForm` to avoid completely reload when scene objects not changed. +* Updated callback to `SceneTree`. +* Updated `ObjectSetting` and `CameraSetting` to support setting from config. +* Updated `Slider` to be able change value with `TextEdit` ### Removed diff --git a/src/compas_viewer/commands.py b/src/compas_viewer/commands.py index e8bb487c1..77dc03eeb 100644 --- a/src/compas_viewer/commands.py +++ b/src/compas_viewer/commands.py @@ -114,7 +114,8 @@ def change_view(viewer: "Viewer", mode: Literal["Perspective", "Top", "Front", " def camera_settings(viewer: "Viewer"): - CameraSettingsDialog().exec() + items = viewer.config.camera.dialog_settings + CameraSettingsDialog(items=items).exec() camera_settings_cmd = Command(title="Camera Settings", callback=camera_settings) diff --git a/src/compas_viewer/components/camerasetting.py b/src/compas_viewer/components/camerasetting.py index ade97bbdd..09cfb2310 100644 --- a/src/compas_viewer/components/camerasetting.py +++ b/src/compas_viewer/components/camerasetting.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import QVBoxLayout from compas_viewer.base import Base -from compas_viewer.components.layout import base_layout +from compas_viewer.components.layout import SettingLayout class CameraSettingsDialog(QDialog, Base): @@ -12,6 +12,11 @@ class CameraSettingsDialog(QDialog, Base): This dialog allows users to modify the camera's target and position and applies these changes dynamically. + Parameters + ---------- + items : list + A list of dictionaries containing the settings for the camera. + Attributes ---------- layout : QVBoxLayout @@ -30,38 +35,19 @@ class CameraSettingsDialog(QDialog, Base): Example ------- - >>> dialog = CameraSettingsDialog() + >>> dialog = CameraSettingsDialog(items=items) >>> dialog.exec() """ - def __init__(self) -> None: + def __init__(self, items: list[dict]) -> None: super().__init__() self.setWindowTitle("Camera Settings") self.layout = QVBoxLayout(self) - self.camera = self.viewer.renderer.camera - items = [ - { - "title": "Camera_Target", - "items": [ - {"type": "double_edit", "title": "X", "value": self.camera.target.x, "min_val": None, "max_val": None}, - {"type": "double_edit", "title": "Y", "value": self.camera.target.y, "min_val": None, "max_val": None}, - {"type": "double_edit", "title": "Z", "value": self.camera.target.z, "min_val": None, "max_val": None}, - ], - }, - { - "title": "Camera_Position", - "items": [ - {"type": "double_edit", "title": "X", "value": self.camera.position.x, "min_val": None, "max_val": None}, - {"type": "double_edit", "title": "Y", "value": self.camera.position.y, "min_val": None, "max_val": None}, - {"type": "double_edit", "title": "Z", "value": self.camera.position.z, "min_val": None, "max_val": None}, - ], - }, - ] - - camera_setting_layout, self.spin_boxes = base_layout(items) + self.setting_layout = SettingLayout(viewer=self.viewer, items=items, type="camera_setting") + self.setting_layout.generate_layout() - self.layout.addLayout(camera_setting_layout) + self.layout.addLayout(self.setting_layout.layout) self.update_button = QPushButton("Update Camera", self) self.update_button.clicked.connect(self.update) @@ -69,13 +55,13 @@ def __init__(self) -> None: def update(self) -> None: self.viewer.renderer.camera.target.set( - self.spin_boxes["Camera_Target_X"].spinbox.value(), - self.spin_boxes["Camera_Target_Y"].spinbox.value(), - self.spin_boxes["Camera_Target_Z"].spinbox.value(), + self.setting_layout.widgets["Camera_Target_X_double_edit"].spinbox.value(), + self.setting_layout.widgets["Camera_Target_Y_double_edit"].spinbox.value(), + self.setting_layout.widgets["Camera_Target_Z_double_edit"].spinbox.value(), ) self.viewer.renderer.camera.position.set( - self.spin_boxes["Camera_Position_X"].spinbox.value(), - self.spin_boxes["Camera_Position_Y"].spinbox.value(), - self.spin_boxes["Camera_Position_Z"].spinbox.value(), + self.setting_layout.widgets["Camera_Position_X_double_edit"].spinbox.value(), + self.setting_layout.widgets["Camera_Position_Y_double_edit"].spinbox.value(), + self.setting_layout.widgets["Camera_Position_Z_double_edit"].spinbox.value(), ) self.accept() diff --git a/src/compas_viewer/components/color.py b/src/compas_viewer/components/color.py new file mode 100644 index 000000000..ee113a5bb --- /dev/null +++ b/src/compas_viewer/components/color.py @@ -0,0 +1,203 @@ +from typing import TYPE_CHECKING + +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog +from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QWidget + +from compas.colors import Color +from compas.colors.colordict import ColorDict +from compas_viewer.base import Base +from compas_viewer.components.combobox import ComboBox + +if TYPE_CHECKING: + from compas_viewer.scene import ViewerSceneObject + + +def remap_rgb(value, to_range_one=True): + """ + Remap an RGB value between the range (0, 255) and (0, 1). + + Parameters + ---------- + value : tuple + The RGB value to remap. + to_range_one : bool, optional + If True, remap from (0, 255) to (0, 1). If False, remap from (0, 1) to (0, 255). + + Returns + ------- + tuple + The remapped RGB value. + """ + factor = 1 / 255 if to_range_one else 255 + return tuple(v * factor for v in value) + + +class ColorComboBox(QWidget, Base): + """ + A custom QWidget for selecting colors from a predefined list and applying the selected color to an object's attribute. + + Parameters + ---------- + obj : ViewerSceneObject, optional + The object to which the selected color will be applied. Defaults to None. + attr : str, optional + The attribute of the object to which the selected color will be applied. Defaults to None. + + Attributes + ---------- + obj : ViewerSceneObject + The object to which the selected color will be applied. + attr : str + The attribute of the object to which the selected color will be applied. + color_options : list of QColor + A list of predefined QColor objects representing available colors. + layout : QVBoxLayout + The layout of the widget. + color_selector : ComboBox + A combo box for selecting colors. + + Methods + ------- + change_color(color: QColor) -> None + Changes the color of the object's attribute to the selected color. + + Example + ------- + >>> color_combobox = ColorComboBox(obj=some_obj, attr="linecolor") + >>> color_combobox.show() + """ + + def __init__( + self, + obj: "ViewerSceneObject" = None, + attr: str = None, + ): + super().__init__() + self.obj = obj + self.attr = attr + + self.color_options = [ + QColor(255, 255, 255), # White + QColor(211, 211, 211), # LightGray + QColor(190, 190, 190), # Gray + QColor(0, 0, 0), # Black + QColor(255, 0, 0), # Red + QColor(0, 255, 0), # Green + QColor(0, 0, 255), # Blue + QColor(255, 255, 0), # Yellow + QColor(0, 255, 255), # Cyan + QColor(255, 0, 255), # Magenta + ] + + default_color = getattr(self.obj, self.attr) + + if isinstance(default_color, Color): + default_color = default_color.rgb + elif isinstance(default_color, ColorDict): + default_color = default_color.default + else: + raise ValueError("Invalid color type.") + default_color = QColor(*remap_rgb(default_color, to_range_one=False)) + + self.layout = QVBoxLayout(self) + self.color_selector = ComboBox(self.color_options, self.change_color, paint=True) + self.color_selector.setAssignedColor(default_color) + self.layout.addWidget(self.color_selector) + + def change_color(self, color): + rgb = remap_rgb(color.getRgb())[:-1] # rgba to rgb(0-1) + setattr(self.obj, self.attr, Color(*rgb)) + self.obj.update() + + +class ColorDialog(QWidget): + """ + A custom QWidget that provides a QPushButton to open a QColorDialog for selecting colors. + + This class is used to manage and display a color attribute of a ViewerSceneObject. + The button shows the current color and allows the user to change the color via a color dialog. + + Parameters + ---------- + obj : ViewerSceneObject, optional + The object whose color attribute is being managed. + attr : str, optional + The attribute name of the color in the object. + + Attributes + ---------- + obj : ViewerSceneObject + The object whose color attribute is being managed. + attr : str + The attribute name of the color in the object. + color_button : QPushButton + The button that displays the current color and opens the color dialog when clicked. + layout : QVBoxLayout + The layout of the widget, which contains the color button. + current_color : QColor + The currently selected color. + + Methods + ------- + open_color_dialog() + Opens a QColorDialog for the user to select a new color. + set_button_color(color: QColor) + Sets the button's background and text to the provided color. + change_color(color: QColor) + Changes the color attribute of the object to the provided color and updates the object. + + Example + ------- + >>> obj = ViewerSceneObject() # Assume this is a valid object with a color attribute + >>> color_button = ColorButton(obj=obj, attr="linecolor") + >>> layout = QVBoxLayout() + >>> layout.addWidget(color_button) + >>> window = QWidget() + >>> window.setLayout(layout) + >>> window.show() + """ + + def __init__( + self, + obj: "ViewerSceneObject" = None, + attr: str = None, + ): + super().__init__() + + self.obj = obj + self.attr = attr + + default_color = getattr(self.obj, self.attr) + if isinstance(default_color, Color): + default_color = default_color.rgb + elif isinstance(default_color, ColorDict): + default_color = default_color.default + else: + raise ValueError("Invalid color type.") + default_color = QColor(*remap_rgb(default_color, to_range_one=False)) + + self.color_button = QPushButton(self) + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.color_button) + self.color_button.clicked.connect(self.open_color_dialog) + self.set_button_color(default_color) + + def open_color_dialog(self): + color = QColorDialog.getColor() + + if color.isValid(): + self.change_color(color) + self.set_button_color(color) + + def set_button_color(self, color: QColor): + self.color_button.setStyleSheet(f"background-color: {color.name()};") + self.color_button.setText(color.name()) + self.current_color = color + + def change_color(self, color): + rgb = remap_rgb(color.getRgb())[:-1] # rgba to rgb(0-1) + setattr(self.obj, self.attr, Color(*rgb)) + self.obj.update() diff --git a/src/compas_viewer/components/combobox.py b/src/compas_viewer/components/combobox.py index 18857211c..79fdf0dc2 100644 --- a/src/compas_viewer/components/combobox.py +++ b/src/compas_viewer/components/combobox.py @@ -1,4 +1,3 @@ -from typing import TYPE_CHECKING from typing import Callable from typing import Optional @@ -11,33 +10,8 @@ from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget -from compas.colors import Color -from compas.colors.colordict import ColorDict from compas_viewer.base import Base -if TYPE_CHECKING: - from compas_viewer.scene import ViewerSceneObject - - -def remap_rgb(value, to_range_one=True): - """ - Remap an RGB value between the range (0, 255) and (0, 1). - - Parameters - ---------- - value : tuple - The RGB value to remap. - to_range_one : bool, optional - If True, remap from (0, 255) to (0, 1). If False, remap from (0, 1) to (0, 255). - - Returns - ------- - tuple - The remapped RGB value. - """ - factor = 1 / 255 if to_range_one else 255 - return tuple(v * factor for v in value) - class ColorDelegate(QStyledItemDelegate): def paint(self, painter, option, index): @@ -171,84 +145,6 @@ def paintEvent(self, event) -> None: painter.end() -class ColorComboBox(QWidget, Base): - """ - A custom QWidget for selecting colors from a predefined list and applying the selected color to an object's attribute. - - Parameters - ---------- - obj : ViewerSceneObject, optional - The object to which the selected color will be applied. Defaults to None. - attr : str, optional - The attribute of the object to which the selected color will be applied. Defaults to None. - - Attributes - ---------- - obj : ViewerSceneObject - The object to which the selected color will be applied. - attr : str - The attribute of the object to which the selected color will be applied. - color_options : list of QColor - A list of predefined QColor objects representing available colors. - layout : QVBoxLayout - The layout of the widget. - color_selector : ComboBox - A combo box for selecting colors. - - Methods - ------- - change_color(color: QColor) -> None - Changes the color of the object's attribute to the selected color. - - Example - ------- - >>> color_combobox = ColorComboBox(obj=some_obj, attr="linecolor") - >>> color_combobox.show() - """ - - def __init__( - self, - obj: "ViewerSceneObject" = None, - attr: str = None, - ): - super().__init__() - self.obj = obj - self.attr = attr - - self.color_options = [ - QColor(255, 255, 255), # White - QColor(211, 211, 211), # LightGray - QColor(190, 190, 190), # Gray - QColor(0, 0, 0), # Black - QColor(255, 0, 0), # Red - QColor(0, 255, 0), # Green - QColor(0, 0, 255), # Blue - QColor(255, 255, 0), # Yellow - QColor(0, 255, 255), # Cyan - QColor(255, 0, 255), # Magenta - ] - - default_color = getattr(self.obj, self.attr) - - if isinstance(default_color, Color): - default_color = default_color.rgb - elif isinstance(default_color, ColorDict): - default_color = default_color.default - else: - raise ValueError("Invalid color type.") - default_color = QColor(*remap_rgb(default_color, to_range_one=False)) - - self.layout = QVBoxLayout(self) - self.color_selector = ComboBox(self.color_options, self.change_color, paint=True) - self.color_selector.setAssignedColor(default_color) - self.layout.addWidget(self.color_selector) - - def change_color(self, color): - rgb = remap_rgb(color.getRgb())[:-1] # rgba to rgb(0-1) - setattr(self.obj, self.attr, Color(*rgb)) - self.obj.update() - - class ViewModeAction(QWidget, Base): def __init__(self): super().__init__() diff --git a/src/compas_viewer/components/double_edit.py b/src/compas_viewer/components/double_edit.py index 1ae13e487..1a99979eb 100644 --- a/src/compas_viewer/components/double_edit.py +++ b/src/compas_viewer/components/double_edit.py @@ -47,9 +47,12 @@ def __init__( if max_val is None: max_val = sys.float_info.max - self.layout = QtWidgets.QHBoxLayout() + self._default_layout = None + self.layout = self.default_layout self.label = QtWidgets.QLabel(title) self.spinbox = QtWidgets.QDoubleSpinBox() + self.spinbox.setDecimals(1) + self.spinbox.setSingleStep(0.1) self.spinbox.setMinimum(min_val) self.spinbox.setMaximum(max_val) self.spinbox.setValue(value) @@ -57,26 +60,10 @@ def __init__( self.layout.addWidget(self.spinbox) self.setLayout(self.layout) + @property + def default_layout(self): + if self._default_layout is None: + from compas_viewer.components.layout import DefaultLayout -class DoubleEditGroup(QtWidgets.QWidget): - def __init__( - self, - title: str, - settings: list[tuple[str, float, float, float]], - ): - super().__init__() - self.layout = QtWidgets.QVBoxLayout(self) - - self.group_box = QtWidgets.QGroupBox(title) - group_layout = QtWidgets.QVBoxLayout() - - for setting in settings: - widget = DoubleEdit(*setting) - group_layout.addWidget(widget) - - group_layout.setSpacing(4) - group_layout.setContentsMargins(4, 4, 4, 4) - self.group_box.setLayout(group_layout) - - self.layout.addWidget(self.group_box) - self.setLayout(self.layout) + self._default_layout = DefaultLayout(QtWidgets.QHBoxLayout()) + return self._default_layout diff --git a/src/compas_viewer/components/label.py b/src/compas_viewer/components/label.py index 688b4d700..8a119c9f8 100644 --- a/src/compas_viewer/components/label.py +++ b/src/compas_viewer/components/label.py @@ -54,6 +54,14 @@ def __init__(self, text: str, alignment: Literal["right", "left", "center"] = "c self.update_minimum_size() + @property + def default_layout(self): + if self._default_layout is None: + from compas_viewer.components.layout import DefaultLayout + + self._default_layout = DefaultLayout(QtWidgets.QHBoxLayout()) + return self._default_layout + @property def text(self): return self.label.text() diff --git a/src/compas_viewer/components/layout.py b/src/compas_viewer/components/layout.py index aa966d4de..7e49e0666 100644 --- a/src/compas_viewer/components/layout.py +++ b/src/compas_viewer/components/layout.py @@ -1,72 +1,159 @@ +from typing import TYPE_CHECKING +from typing import Any +from typing import Callable +from typing import Literal + from PySide6.QtWidgets import QHBoxLayout from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QLayout from PySide6.QtWidgets import QVBoxLayout -from compas_viewer.components.combobox import ColorComboBox +from compas_viewer.components.color import ColorComboBox +from compas_viewer.components.color import ColorDialog from compas_viewer.components.double_edit import DoubleEdit from compas_viewer.components.label import LabelWidget +from compas_viewer.components.textedit import TextEdit + +if TYPE_CHECKING: + from compas_viewer import Viewer + + +class DefaultLayout: + """ + A class to create a default layout with minimal spacing and margins. + + Parameters + ---------- + layout : QLayout + + Attributes + ---------- + layout : QLayout + The layout with minimized spacing and margins. + """ + + def __new__(cls, layout: QLayout) -> QLayout: + layout.setSpacing(0) # Minimize the spacing between items + layout.setContentsMargins(0, 0, 0, 0) # Minimize the margins + return layout -def base_layout(items: list) -> tuple[QVBoxLayout, dict]: +class SettingLayout: """ - Generates a layout for editing properties based on provided items and settings. + A class to generate a dynamic layout for displaying and editing settings of objects or camera in a viewer. + + This class can generate a layout based on the provided items and the type of settings (object or camera). + It supports various types of widgets including double edits, labels, color dialogs, and text edits. Parameters ---------- + viewer : Viewer + The viewer instance containing the scene and objects or camera. items : list A list of dictionaries where each dictionary represents a section with a title and items describing the widgets and their parameters. + type : Literal["obj_setting", "camera_setting"] + The type of settings to generate the layout for. It can be "obj_setting" for object settings or "camera_setting" for camera settings. + + Attributes + ---------- + layout : QVBoxLayout + The main layout of the widget. + widgets : dict + A dictionary to store the created widgets for easy access. - Returns + Methods ------- - tuple[QVBoxLayout, dict] - A tuple containing the created layout and a dictionary of spin boxes for value adjustment. + generate_layout(viewer, items) + Generates the layout based on the provided viewer and items. + set_layout(items, obj) + Sets the layout for the provided items and object. Example ------- >>> items = [ - >>> {"title": "Camera_Target", "items": [{"type": "double_edit", "title": "X", "value": 0.0, "min_val": 0.0, "max_val": 1.0}]}, - >>> {"title": "Camera_Position", "items": [{"type": "double_edit", "title": "Y", "value": 1.0, "min_val": 0.0, "max_val": 1.0}]} + >>> {"title": "Name", "items": [{"type": "text_edit", "action": lambda obj: obj.name}]}, + >>> {"title": "Point_Color", "items": [{"type": "color_dialog", "attr": "pointcolor"}]}, + >>> {"title": "Line_Color", "items": [{"type": "color_dialog", "attr": "linecolor"}]}, + >>> {"title": "Face_Color", "items": [{"type": "color_dialog", "attr": "facecolor"}]}, + >>> {"title": "Line_Width", "items": [{"type": "double_edit", "action": lambda obj: obj.linewidth, "min_val": 0.0, "max_val": 10.0}]}, + >>> {"title": "Point_Size", "items": [{"type": "double_edit", "action": lambda obj: obj.pointsize, "min_val": 0.0, "max_val": 10.0}]}, + >>> {"title": "Opacity", "items": [{"type": "double_edit", "action": lambda obj: obj.opacity, "min_val": 0.0, "max_val": 1.0}]}, >>> ] - >>> layout, spin_boxes = base_layout(items) """ - layout = QVBoxLayout() - spin_boxes = {} + def __init__( + self, + viewer: "Viewer", + items: list[dict], + type: Literal["obj_setting", "camera_setting"], + ): + super().__init__() - for item in items: - l_title = item.get("title", "") - sub_items = item.get("items", None) + self.viewer = viewer + self.items = items + self.type = type - sub_layout = QHBoxLayout() - left_layout = QVBoxLayout() - right_layout = QHBoxLayout() + def generate_layout(self) -> None: + self.layout = QVBoxLayout() + self.widgets = {} - label = QLabel(f"{l_title}:") - left_layout.addWidget(label) + if self.type == "camera_setting": + self.set_layout(self.items, self.viewer.renderer.camera) - for sub_item in sub_items: - r_title = sub_item.get("title", "") - type = sub_item.get("type", None) - text = sub_item.get("text", "") - obj = sub_item.get("obj", None) - attr = sub_item.get("attr", None) - value = sub_item.get("value", None) - min_val = sub_item.get("min_val", None) - max_val = sub_item.get("max_val", None) + elif self.type == "obj_setting": + obj_list = [] + for obj in self.viewer.scene.objects: + if obj.is_selected: + obj_list.append(obj) - if type == "double_edit": - widget = DoubleEdit(title=r_title, value=value, min_val=min_val, max_val=max_val) - right_layout.addWidget(widget) - spin_boxes[f"{l_title}_{r_title}"] = widget - elif type == "label": - widget = LabelWidget(text=text, alignment="right") - right_layout.addWidget(widget) - elif type == "color_combobox": - widget = ColorComboBox(obj=obj, attr=attr) + if not obj_list: + return + # Only support one item selected per time + self.set_layout(self.items, obj_list[0]) + + def set_layout(self, items: list[dict], obj: Any) -> None: + for item in items: + layout_title = item.get("title", "") + sub_items = item.get("items", None) + + sub_layout = DefaultLayout(QHBoxLayout()) + left_layout = DefaultLayout(QHBoxLayout()) + right_layout = DefaultLayout(QHBoxLayout()) + + label = QLabel(f"{layout_title}:") + left_layout.addWidget(label) + + for sub_item in sub_items: + sub_title: str = sub_item.get("title", None) + type: str = sub_item.get("type", None) + action: Callable[[Any], Any] = sub_item.get("action", None) + attr: str = sub_item.get("attr", None) + min_val: float = sub_item.get("min_val", None) + max_val: float = sub_item.get("max_val", None) + + if type == "double_edit": + value = action(obj) + widget = DoubleEdit(title=sub_title, value=value, min_val=min_val, max_val=max_val) + elif type == "label": + text = action(obj) + widget = LabelWidget(text=text, alignment="center") + elif type == "color_combobox": + widget = ColorComboBox(obj=obj, attr=attr) + elif type == "text_edit": + text = str(action(obj)) + widget = TextEdit(text=text) + elif type == "color_dialog": + widget = ColorDialog(obj=obj, attr=attr) + + if sub_title is None: + widget_name = f"{layout_title}_{type}" + else: + widget_name = f"{layout_title}_{sub_title}_{type}" + + self.widgets[widget_name] = widget right_layout.addWidget(widget) - sub_layout.addLayout(left_layout) - sub_layout.addLayout(right_layout) + sub_layout.addLayout(left_layout) + sub_layout.addLayout(right_layout) - layout.addLayout(sub_layout) - return (layout, spin_boxes) + self.layout.addLayout(sub_layout) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 3e43642c6..2a536befb 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -1,59 +1,23 @@ from typing import TYPE_CHECKING +from PySide6.QtCore import Qt from PySide6.QtCore import Signal from PySide6.QtWidgets import QDialog from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QScrollArea from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget from compas_viewer.base import Base -from compas_viewer.components.layout import base_layout +from compas_viewer.components.double_edit import DoubleEdit +from compas_viewer.components.label import LabelWidget +from compas_viewer.components.layout import SettingLayout +from compas_viewer.components.textedit import TextEdit if TYPE_CHECKING: from compas_viewer import Viewer -def object_setting_layout(viewer: "Viewer"): - """ - Generates a layout for displaying and editing object information based on the selected objects in the viewer. - - Parameters - ---------- - viewer : Viewer - The viewer instance containing the scene and objects. - - Returns - ------- - QVBoxLayout - The layout for displaying object information, or None if no objects are selected. - - Example - ------- - >>> layout = object_setting_layout(viewer) - """ - status = False - items = [] - for obj in viewer.scene.objects: - if obj.is_selected: - status = True - new_items = [ - {"title": "Name", "items": [{"type": "label", "text": str(obj.name)}]}, - {"title": "Parent", "items": [{"type": "label", "text": str(obj.parent)}]}, - {"title": "Point_Color", "items": [{"type": "color_combobox", "obj": obj, "attr": "pointcolor"}]}, - {"title": "Line_Color", "items": [{"type": "color_combobox", "obj": obj, "attr": "linecolor"}]}, - {"title": "Face_Color", "items": [{"type": "color_combobox", "obj": obj, "attr": "facecolor"}]}, - {"title": "Line_Width", "items": [{"type": "double_edit", "title": "", "value": obj.linewidth, "min_val": 0.0, "max_val": 10.0}]}, - {"title": "Point_Size", "items": [{"type": "double_edit", "title": "", "value": obj.pointsize, "min_val": 0.0, "max_val": 10.0}]}, - {"title": "Opacity", "items": [{"type": "double_edit", "title": "", "value": obj.opacity, "min_val": 0.0, "max_val": 1.0}]}, - ] - items.extend(new_items) - - if not status: - return None - - return base_layout(items) - - class ObjectSetting(QWidget): """ A QWidget to manage the settings of objects in the viewer. @@ -62,17 +26,19 @@ class ObjectSetting(QWidget): ---------- viewer : Viewer The viewer instance containing the objects. + items : list + A list of dictionaries containing the settings for the object. Attributes ---------- viewer : Viewer The viewer instance. + items : list + A list of dictionaries containing the settings for the object. layout : QVBoxLayout The main layout for the widget. update_button : QPushButton The button to trigger the object update. - spin_boxes : dict - Dictionary to hold spin boxes for object properties. Methods ------- @@ -86,11 +52,23 @@ class ObjectSetting(QWidget): update_requested = Signal() - def __init__(self, viewer: "Viewer"): + def __init__(self, viewer: "Viewer", items: list[dict]): super().__init__() self.viewer = viewer - self.layout = QVBoxLayout(self) - self.spin_boxes = {} + self.items = items + self.setting_layout = SettingLayout(viewer=self.viewer, items=self.items, type="obj_setting") + # Main layout + self.main_layout = QVBoxLayout(self) + + # Scroll area setup + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_content = QWidget() + self.scroll_layout = QVBoxLayout(self.scroll_content) + self.scroll_layout.setAlignment(Qt.AlignTop) + self.scroll_area.setWidget(self.scroll_content) + + self.main_layout.addWidget(self.scroll_area) def clear_layout(self, layout): """Clear all widgets from the layout.""" @@ -106,24 +84,27 @@ def clear_layout(self, layout): def update(self): """Update the layout with the latest object settings.""" - self.clear_layout(self.layout) - output = object_setting_layout(self.viewer) - - if output is not None: - text = "Update Object" - obj_setting_layout, self.spin_boxes = output - self.layout.addLayout(obj_setting_layout) - self.update_button = QPushButton(text, self) - self.update_button.clicked.connect(self.obj_update) - self.layout.addWidget(self.update_button) + self.clear_layout(self.scroll_layout) + self.setting_layout.generate_layout() + + if len(self.setting_layout.widgets) != 0: + self.scroll_layout.addLayout(self.setting_layout.layout) + for _, widget in self.setting_layout.widgets.items(): + if isinstance(widget, DoubleEdit): + widget.spinbox.valueChanged.connect(self.obj_update) + elif isinstance(widget, TextEdit): + widget.text_edit.textChanged.connect(self.obj_update) + else: + self.scroll_layout.addWidget(LabelWidget(text="No object Selected", alignment="center")) def obj_update(self): """Apply the settings from spin boxes to the selected objects.""" for obj in self.viewer.scene.objects: if obj.is_selected: - obj.linewidth = self.spin_boxes["Line_Width_"].spinbox.value() - obj.pointsize = self.spin_boxes["Point_Size_"].spinbox.value() - obj.opacity = self.spin_boxes["Opacity_"].spinbox.value() + obj.name = self.setting_layout.widgets["Name_text_edit"].text_edit.toPlainText() + obj.linewidth = self.setting_layout.widgets["Line_Width_double_edit"].spinbox.value() + obj.pointsize = self.setting_layout.widgets["Point_Size_double_edit"].spinbox.value() + obj.opacity = self.setting_layout.widgets["Opacity_double_edit"].spinbox.value() obj.update() @@ -133,12 +114,17 @@ class ObjectSettingDialog(QDialog, Base): This dialog allows users to modify object properties such as line width, point size, and opacity, and applies these changes dynamically. + Parameters + ---------- + items : list + A list of dictionaries containing the settings for the object. + Attributes ---------- layout : QVBoxLayout The layout of the dialog. - spin_boxes : dict - Dictionary containing spin boxes for adjusting object properties. + items : list + A list of dictionaries containing the settings for the object. update_button : QPushButton Button to apply changes to the selected objects. @@ -153,17 +139,16 @@ class ObjectSettingDialog(QDialog, Base): >>> dialog.exec() """ - def __init__(self) -> None: + def __init__(self, items: list[dict]) -> None: super().__init__() - + self.items = items self.setWindowTitle("Object Settings") self.layout = QVBoxLayout(self) - output = object_setting_layout(self.viewer) + self.setting_layout = SettingLayout(viewer=self.viewer, items=self.items, type="obj_setting") - if output is not None: + if self.setting_layout is not None: text = "Update Object" - obj_setting_layout, self.spin_boxes = output - self.layout.addLayout(obj_setting_layout) + self.layout.addLayout(self.setting_layout.layout) else: text = "No object selected." @@ -174,9 +159,9 @@ def __init__(self) -> None: def obj_update(self) -> None: for obj in self.viewer.scene.objects: if obj.is_selected: - obj.linewidth = self.spin_boxes["Line_Width_"].spinbox.value() - obj.pointsize = self.spin_boxes["Point_Size_"].spinbox.value() - obj.opacity = self.spin_boxes["Opacity_"].spinbox.value() + obj.linewidth = self.setting_layout.widgets["Line_Width_double_edit"].spinbox.value() + obj.pointsize = self.setting_layout.widgets["Point_Size_double_edit"].spinbox.value() + obj.opacity = self.setting_layout.widgets["Opacity_double_edit"].spinbox.value() obj.update() self.accept() diff --git a/src/compas_viewer/components/sceneform.py b/src/compas_viewer/components/sceneform.py index 55dee93de..a0f9d1322 100644 --- a/src/compas_viewer/components/sceneform.py +++ b/src/compas_viewer/components/sceneform.py @@ -48,6 +48,7 @@ def __init__( self.setColumnCount(len(columns)) self.setHeaderLabels(col["title"] for col in self.columns) self.setHeaderHidden(not show_headers) + self.setSelectionMode(QTreeWidget.SingleSelection) self._sceneobjects = [] self.callback = callback @@ -135,6 +136,8 @@ def on_item_clicked(self, item, column): if self.callback and node.is_selected: self.callback(self, node) + self.viewer.ui.sidebar.update() + self.viewer.renderer.update() def on_item_selection_changed(self): diff --git a/src/compas_viewer/components/slider.py b/src/compas_viewer/components/slider.py index ed9658796..1b085f910 100644 --- a/src/compas_viewer/components/slider.py +++ b/src/compas_viewer/components/slider.py @@ -8,6 +8,8 @@ from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget +from compas_viewer.components.textedit import TextEdit + class Slider(QWidget): def __init__( @@ -17,7 +19,6 @@ def __init__( max_val: float = 100, step: Optional[float] = 1, action: Callable = None, - horizontal: Optional[bool] = True, starting_val: Optional[float] = None, tick_interval: Optional[float] = None, ): @@ -72,43 +73,54 @@ def __init__( super().__init__() self.title = title self.action = action - self._horizontal = horizontal self.min_val = min_val self.max_val = max_val self.step = step or 1 self.starting_val = starting_val if starting_val is not None else self.min_val self._tick_interval = tick_interval if tick_interval is not None else (self._scaled_max_val - self._scaled_min_val) / 10 - orientation = Qt.Horizontal if horizontal else Qt.Vertical + self._updating = False + + self._default_layout = None + self.layout = self.default_layout - self.layout = QVBoxLayout(self) - self._h_layout = QHBoxLayout() - self.slider = QSlider(orientation) + self._text_layout = QHBoxLayout() + self._domain_layout = QHBoxLayout() + self.slider = QSlider(Qt.Horizontal) self.slider.setMinimum(self._scaled_min_val) self.slider.setMaximum(self._scaled_max_val) self.slider.setTickInterval(self._tick_interval) self.slider.setTickPosition(QSlider.TicksBelow) self.slider.setValue(self.starting_val) + # Connect the slider movement to the callback + self.slider.valueChanged.connect(self.on_value_changed) # Labels for displaying the range and current value self._min_label = QLabel(str(self.min_val), alignment=Qt.AlignLeft) self._max_label = QLabel(str(self.max_val), alignment=Qt.AlignRight) self.value_label = QLabel(f"{self.title}:") + self.text_edit = TextEdit(str(self.starting_val)) + self.text_edit.text_edit.textChanged.connect(self.text_update) - # Connect the slider movement to the callback - self.slider.valueChanged.connect(self.on_value_changed) - self.layout.addWidget(self.value_label) - self.layout.addWidget(self.slider) + self._text_layout.addWidget(self.value_label) + self._text_layout.addWidget(self.text_edit.text_edit) # Add widgets to layout - if orientation == Qt.Horizontal: - self._h_layout.addWidget(self._min_label) - self._h_layout.addWidget(self._max_label) - else: - self._h_layout.addWidget(self._min_label, 0, Qt.AlignTop) - self._h_layout.addWidget(self._max_label, 0, Qt.AlignBottom) + self._domain_layout.addWidget(self._min_label) + self._domain_layout.addWidget(self._max_label) - self.layout.addLayout(self._h_layout) + self.layout.addLayout(self._text_layout) + self.layout.addWidget(self.slider) + self.layout.addLayout(self._domain_layout) + self.setLayout(self.layout) + + @property + def default_layout(self): + if self._default_layout is None: + from compas_viewer.components.layout import DefaultLayout + + self._default_layout = DefaultLayout(QVBoxLayout()) + return self._default_layout @property def _scaled_min_val(self): @@ -119,8 +131,24 @@ def _scaled_max_val(self): return self.max_val / self.step def on_value_changed(self, value): - """ - Update the label based on the slider's current value. - """ - self.value_label.setText(f"{self.title}: {round(value * self.step, 2)}") - self.action(self, value * self.step) + if self._updating: + return + self._updating = True + scaled_value = round(value * self.step, 2) + self.text_edit.text_edit.setText(str(scaled_value)) + if self.action: + self.action(self, scaled_value) + self._updating = False + + def text_update(self): + if self._updating: + return + self._updating = True + try: + value = float(self.text_edit.text_edit.toPlainText()) / self.step + self.slider.setValue(value) + if self.action: + self.action(self, value * self.step) + except ValueError: + pass # Handle cases where the text is not a valid number + self._updating = False diff --git a/src/compas_viewer/components/textedit.py b/src/compas_viewer/components/textedit.py new file mode 100644 index 000000000..bf34df817 --- /dev/null +++ b/src/compas_viewer/components/textedit.py @@ -0,0 +1,38 @@ +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QSizePolicy +from PySide6.QtWidgets import QTextEdit +from PySide6.QtWidgets import QWidget + + +class TextEdit(QWidget): + """ + A customizable QTextEdit widget for Qt applications, supporting text alignment and font size adjustments. + """ + + def __init__( + self, + text: str = None, + ): + super().__init__() + + self._default_layout = None + + self.text_edit = QTextEdit() + self.text_edit.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.text_edit.setMaximumSize(85, 25) + self.text_edit.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.text_edit.setText(text) + + self.layout = self.default_layout + self.layout.setAlignment(Qt.AlignCenter) + self.layout.addWidget(self.text_edit) + self.setLayout(self.layout) + + @property + def default_layout(self): + if self._default_layout is None: + from compas_viewer.components.layout import DefaultLayout + + self._default_layout = DefaultLayout(QHBoxLayout()) + return self._default_layout diff --git a/src/compas_viewer/config.py b/src/compas_viewer/config.py index 3c4da0fc2..4dac80c12 100644 --- a/src/compas_viewer/config.py +++ b/src/compas_viewer/config.py @@ -244,6 +244,7 @@ class StatusbarConfig(ConfigBase): @dataclass class SidebarConfig(ConfigBase): show: bool = True + show_widgets: bool = True sceneform: bool = True items: list[dict] = field( default_factory=lambda: [ @@ -254,6 +255,18 @@ class SidebarConfig(ConfigBase): {"title": "Show", "type": "checkbox", "checked": lambda obj: obj.show, "action": lambda obj, checked: setattr(obj, "show", checked)}, ], }, + { + "type": "ObjectSetting", + "items": [ + {"title": "Name", "items": [{"type": "text_edit", "action": lambda obj: obj.name}]}, + {"title": "Point_Color", "items": [{"type": "color_dialog", "attr": "pointcolor"}]}, + {"title": "Line_Color", "items": [{"type": "color_dialog", "attr": "linecolor"}]}, + {"title": "Face_Color", "items": [{"type": "color_dialog", "attr": "facecolor"}]}, + {"title": "Line_Width", "items": [{"type": "double_edit", "action": lambda obj: obj.linewidth, "min_val": 0.0, "max_val": 10.0}]}, + {"title": "Point_Size", "items": [{"type": "double_edit", "action": lambda obj: obj.pointsize, "min_val": 0.0, "max_val": 10.0}]}, + {"title": "Opacity", "items": [{"type": "double_edit", "action": lambda obj: obj.opacity, "min_val": 0.0, "max_val": 1.0}]}, + ], + }, ] ) @@ -321,6 +334,26 @@ class CameraConfig(ConfigBase): zoomdelta: float = 0.05 rotationdelta: float = 0.01 pandelta: float = 0.05 + dialog_settings: list[dict] = field( + default_factory=lambda: [ + { + "title": "Camera_Target", + "items": [ + {"type": "double_edit", "title": "X", "action": lambda camera: camera.target.x, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Y", "action": lambda camera: camera.target.y, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Z", "action": lambda camera: camera.target.z, "min_val": None, "max_val": None}, + ], + }, + { + "title": "Camera_Position", + "items": [ + {"type": "double_edit", "title": "X", "action": lambda camera: camera.position.x, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Y", "action": lambda camera: camera.position.y, "min_val": None, "max_val": None}, + {"type": "double_edit", "title": "Z", "action": lambda camera: camera.position.z, "min_val": None, "max_val": None}, + ], + }, + ] + ) @dataclass diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 2e3278f0f..0fe5b52f8 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -5,25 +5,20 @@ from PySide6.QtWidgets import QSplitter from compas_viewer.components import Sceneform -from compas_viewer.components import Treeform from compas_viewer.components.objectsetting import ObjectSetting if TYPE_CHECKING: from .ui import UI -def is_layout_empty(layout): - return layout.count() == 0 - - class SideBarRight: def __init__(self, ui: "UI", show: bool, items: list[dict[str, Callable]]) -> None: self.ui = ui self.widget = QSplitter(QtCore.Qt.Orientation.Vertical) self.widget.setChildrenCollapsible(True) self.show = show + self.hide_widget = True self.items = items - self.sceneform = None def add_items(self) -> None: if not self.items: @@ -34,25 +29,25 @@ def add_items(self) -> None: if itemtype == "Sceneform": columns = item.get("columns", None) - callback = item.get("callback", None) if columns is None: - raise ValueError("Columns not provided for Sceneform") - self.sceneform = Sceneform(columns, callback=callback) + raise ValueError("Please setup config for Sceneform") + self.sceneform = Sceneform(columns=columns) self.widget.addWidget(self.sceneform) - elif itemtype == "Treeform": - item.pop("type") - self.widget.addWidget(Treeform(**item)) + elif itemtype == "ObjectSetting": + items = item.get("items", None) + if items is None: + raise ValueError("Please setup config for ObjectSetting") + self.object_setting = ObjectSetting(viewer=self.ui.viewer, items=items) + self.widget.addWidget(self.object_setting) + + self.show_sceneform = True + self.show_objectsetting = True def update(self): self.widget.update() for widget in self.widget.children(): widget.update() - if isinstance(widget, ObjectSetting): - if is_layout_empty(widget.layout): - widget.hide() - else: - widget.show() @property def show(self): @@ -60,7 +55,20 @@ def show(self): @show.setter def show(self, value: bool): - if value: - self.widget.setVisible(True) - elif not value: - self.widget.setHidden(True) + self.widget.setVisible(value) + + @property + def show_sceneform(self): + return self.sceneform.isVisible() + + @show_sceneform.setter + def show_sceneform(self, value: bool): + self.sceneform.setVisible(value) + + @property + def show_objectsetting(self): + return self.object_setting.isVisible() + + @show_objectsetting.setter + def show_objectsetting(self, value: bool): + self.object_setting.setVisible(value)