From 607689ce7cd53fdcdc62678d4646682890ed8d43 Mon Sep 17 00:00:00 2001 From: Licini Date: Fri, 4 Jul 2025 19:00:53 +0200 Subject: [PATCH 01/18] use composition for UI and a lot of clean up --- src/compas_viewer/base.py | 15 ++ src/compas_viewer/commands.py | 17 -- src/compas_viewer/components/__init__.py | 6 +- src/compas_viewer/components/component.py | 11 + src/compas_viewer/components/objectsetting.py | 235 +++++++----------- src/compas_viewer/components/sceneform.py | 42 ++-- src/compas_viewer/config.py | 15 +- src/compas_viewer/ui/container.py | 37 +++ src/compas_viewer/ui/sidebar.py | 52 +--- src/compas_viewer/ui/ui.py | 2 +- 10 files changed, 194 insertions(+), 238 deletions(-) create mode 100644 src/compas_viewer/components/component.py create mode 100644 src/compas_viewer/ui/container.py diff --git a/src/compas_viewer/base.py b/src/compas_viewer/base.py index 98b07f5524..b479c5d73f 100644 --- a/src/compas_viewer/base.py +++ b/src/compas_viewer/base.py @@ -1,6 +1,21 @@ class Base: + """ + Base class for all components in the viewer, provides a global access to the viewer and scene. + + Attributes + ---------- + viewer : Viewer + The viewer instance. + scene : Scene + The scene instance. + """ + @property def viewer(self): from compas_viewer.viewer import Viewer return Viewer() + + @property + def scene(self): + return self.viewer.scene diff --git a/src/compas_viewer/commands.py b/src/compas_viewer/commands.py index ab798544ca..115cb08bd7 100644 --- a/src/compas_viewer/commands.py +++ b/src/compas_viewer/commands.py @@ -22,7 +22,6 @@ from compas.geometry import Geometry from compas.scene import Scene from compas_viewer.components.camerasetting import CameraSettingsDialog -from compas_viewer.components.objectsetting import ObjectSettingDialog if TYPE_CHECKING: from compas_viewer import Viewer @@ -490,19 +489,3 @@ def load_data(): load_data_cmd = Command(title="Load Data", callback=lambda: print("load data")) - - -# ============================================================================= -# ============================================================================= -# ============================================================================= -# Info -# ============================================================================= -# ============================================================================= -# ============================================================================= - - -def obj_settings(viewer: "Viewer"): - ObjectSettingDialog().exec() - - -obj_settings_cmd = Command(title="Object Settings", callback=obj_settings) diff --git a/src/compas_viewer/components/__init__.py b/src/compas_viewer/components/__init__.py index 585a055cc5..a22d5f8aa1 100644 --- a/src/compas_viewer/components/__init__.py +++ b/src/compas_viewer/components/__init__.py @@ -2,21 +2,23 @@ from .combobox import ComboBox from .combobox import ViewModeAction from .camerasetting import CameraSettingsDialog -from .objectsetting import ObjectSettingDialog from .slider import Slider from .textedit import TextEdit from .treeform import Treeform from .sceneform import Sceneform +from .objectsetting import ObjectSetting +from .component import Component __all__ = [ "Button", "ComboBox", "CameraSettingsDialog", - "ObjectSettingDialog", "Renderer", "Slider", "TextEdit", "Treeform", "Sceneform", + "ObjectSetting", + "Component", "ViewModeAction", ] diff --git a/src/compas_viewer/components/component.py b/src/compas_viewer/components/component.py new file mode 100644 index 0000000000..53a4001c79 --- /dev/null +++ b/src/compas_viewer/components/component.py @@ -0,0 +1,11 @@ +from PySide6.QtWidgets import QWidget + +from compas_viewer.base import Base + + +class Component(Base): + def __init__(self): + self.widget = QWidget() + + def update(self): + self.widget.update() diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 2a536befb3..236864278b 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -1,167 +1,122 @@ -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 QHBoxLayout +from PySide6.QtWidgets import QLabel 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.color import ColorDialog +from compas_viewer.components.component import Component 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 - -class ObjectSetting(QWidget): +class ObjectSetting(Component): """ - A QWidget to manage the settings of objects in the viewer. - - Parameters - ---------- - 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. - - Methods - ------- - clear_layout(layout) - Clears all widgets and sub-layouts from the given layout. - update() - Updates the layout with the latest object settings. - obj_update() - Applies the settings from spin boxes to the selected objects. + A component to manage the settings of objects in the viewer. """ - update_requested = Signal() - - def __init__(self, viewer: "Viewer", items: list[dict]): + def __init__(self): super().__init__() - self.viewer = viewer - 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.widget = QScrollArea() + self.widget.setWidgetResizable(True) + self.reset() + + def reset(self): + """Reset the content widget and layout to a clean state.""" + self.sub_widgets = {} 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.""" - while layout.count(): - item = layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - else: - sub_layout = item.layout() - if sub_layout is not None: - self.clear_layout(sub_layout) + self.widget.setWidget(self.scroll_content) + self.settings_layout = QVBoxLayout() + self.settings_layout.setSpacing(0) + self.settings_layout.setContentsMargins(0, 0, 0, 0) + self.scroll_layout.addLayout(self.settings_layout) + + @property + def selected(self): + return [obj for obj in self.scene.objects if obj.is_selected] + + def add_row(self, attr_name: str, widget: QWidget = None) -> None: + """Create a setting row with label and widget, then add it to the layout.""" + row_layout = QHBoxLayout() + row_layout.setSpacing(0) + row_layout.setContentsMargins(0, 0, 0, 0) + + label = QLabel(attr_name) + row_layout.addWidget(label) + + if widget: + row_layout.addWidget(widget) + self.sub_widgets[attr_name] = widget + + self.settings_layout.addLayout(row_layout) + + def populate(self) -> None: + """Populate the layout with the settings of the selected object.""" + obj = self.selected[0] + + if not obj: + return + + self.add_row("name", TextEdit(text=str(obj.name))) + + if hasattr(obj, "pointcolor") and obj.pointcolor is not None: + self.add_row("pointcolor", ColorDialog(obj=obj, attr="pointcolor")) + + if hasattr(obj, "linecolor") and obj.linecolor is not None: + self.add_row("linecolor", ColorDialog(obj=obj, attr="linecolor")) + + if hasattr(obj, "facecolor") and obj.facecolor is not None: + self.add_row("facecolor", ColorDialog(obj=obj, attr="facecolor")) + + if hasattr(obj, "linewidth") and obj.linewidth is not None: + self.add_row("linewidth", DoubleEdit(title=None, value=obj.linewidth, min_val=0.0, max_val=10.0)) + + if hasattr(obj, "pointsize") and obj.pointsize is not None: + self.add_row("pointsize", DoubleEdit(title=None, value=obj.pointsize, min_val=0.0, max_val=10.0)) + + if hasattr(obj, "opacity") and obj.opacity is not None: + self.add_row("opacity", DoubleEdit(title=None, value=obj.opacity, min_val=0.0, max_val=1.0)) def update(self): """Update the layout with the latest object settings.""" - 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) + self.reset() + + if len(self.selected) == 1: + self.populate() + self._add_event_listeners() + elif len(self.selected) > 1: + self.add_row("Multiple objects selected") else: - self.scroll_layout.addWidget(LabelWidget(text="No object Selected", alignment="center")) + self.add_row("No object selected") - 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.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() + def _add_event_listeners(self): + """Add event listeners to the sub widgets.""" + def _update_obj(): + if len(self.selected) == 0: + return -class ObjectSettingDialog(QDialog, Base): - """ - A dialog for displaying and updating object settings in Qt applications. - 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. - items : list - A list of dictionaries containing the settings for the object. - update_button : QPushButton - Button to apply changes to the selected objects. - - Methods - ------- - update() - Updates the properties of selected objects and closes the dialog. - - Example - ------- - >>> dialog = ObjectInfoDialog() - >>> dialog.exec() - """ + obj = self.selected[0] - def __init__(self, items: list[dict]) -> None: - super().__init__() - self.items = items - self.setWindowTitle("Object Settings") - self.layout = QVBoxLayout(self) - self.setting_layout = SettingLayout(viewer=self.viewer, items=self.items, type="obj_setting") - - if self.setting_layout is not None: - text = "Update Object" - self.layout.addLayout(self.setting_layout.layout) - else: - text = "No object selected." + for attr_name, widget in self.sub_widgets.items(): + if not hasattr(obj, attr_name): + continue + if isinstance(widget, TextEdit): + value = widget.text_edit.toPlainText() + elif isinstance(widget, DoubleEdit): + value = widget.spinbox.value() + else: + continue - self.update_button = QPushButton(text, self) - self.update_button.clicked.connect(self.obj_update) - self.layout.addWidget(self.update_button) + setattr(obj, attr_name, value) - def obj_update(self) -> None: - for obj in self.viewer.scene.objects: - if obj.is_selected: - 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() + obj.update() - self.accept() + for widget in self.sub_widgets.values(): + if isinstance(widget, TextEdit): + widget.text_edit.textChanged.connect(_update_obj) + elif isinstance(widget, DoubleEdit): + widget.spinbox.valueChanged.connect(_update_obj) diff --git a/src/compas_viewer/components/sceneform.py b/src/compas_viewer/components/sceneform.py index 76f39c0356..2879366d5b 100644 --- a/src/compas_viewer/components/sceneform.py +++ b/src/compas_viewer/components/sceneform.py @@ -5,8 +5,10 @@ from PySide6.QtWidgets import QTreeWidget from PySide6.QtWidgets import QTreeWidgetItem +from .component import Component -class Sceneform(QTreeWidget): + +class Sceneform(Component): """ Class for displaying the SceneTree. @@ -26,6 +28,8 @@ class Sceneform(QTreeWidget): Attributes ---------- + widget : QTreeWidget + The tree widget for displaying the scene. scene : :class:`compas.scene.Scene` The scene to be displayed. columns : list[dict] @@ -42,25 +46,21 @@ def __init__( callback: Optional[Callable] = None, ): super().__init__() + + self.widget = QTreeWidget() self.columns = columns self.checkbox_columns: dict[int, str] = {} self.column_editable = (column_editable or [False]) + [False] * (len(columns) - len(column_editable or [False])) - self.setColumnCount(len(columns)) - self.setHeaderLabels(col["title"] for col in self.columns) - self.setHeaderHidden(not show_headers) - self.setSelectionMode(QTreeWidget.SingleSelection) + self.widget.setColumnCount(len(columns)) + self.widget.setHeaderLabels(col["title"] for col in self.columns) + self.widget.setHeaderHidden(not show_headers) + self.widget.setSelectionMode(QTreeWidget.SingleSelection) self._sceneobjects = [] self.callback = callback - self.itemClicked.connect(self.on_item_clicked) - self.itemSelectionChanged.connect(self.on_item_selection_changed) - - @property - def viewer(self): - from compas_viewer import Viewer - - return Viewer() + self.widget.itemClicked.connect(self.on_item_clicked) + self.widget.itemSelectionChanged.connect(self.on_item_selection_changed) @property def scene(self): @@ -74,12 +74,12 @@ def update(self): widget.setSelected(node.is_selected) if node.is_selected: self.expand(node.parent) - self.scrollToItem(widget) + self.widget.scrollToItem(widget) else: self._sceneobjects = list(self.scene.objects) - self.clear() + self.widget.clear() self.checkbox_columns = {} for node in self.scene.traverse("breadthfirst"): @@ -103,7 +103,7 @@ def update(self): raise ValueError("Text must be provided for label") strings.append(text(node)) - parent_widget = self if node.parent.is_root else node.parent.attributes["widget"] + parent_widget = self.widget if node.parent.is_root else node.parent.attributes["widget"] widget = QTreeWidgetItem(parent_widget, strings) widget.node = node widget.setSelected(node.is_selected) @@ -130,8 +130,8 @@ def on_item_clicked(self, item, column): check = self.checkbox_columns[column]["action"] check(item.node, item.checkState(column) == Qt.Checked) - if self.selectedItems(): - selected_nodes = {item.node for item in self.selectedItems()} + if self.widget.selectedItems(): + selected_nodes = {item.node for item in self.widget.selectedItems()} for node in self.scene.objects: node.is_selected = node in selected_nodes if self.callback and node.is_selected: @@ -142,11 +142,11 @@ def on_item_clicked(self, item, column): self.viewer.renderer.update() def on_item_selection_changed(self): - for item in self.selectedItems(): + for item in self.widget.selectedItems(): if self.callback: self.callback(self, item.node) def adjust_column_widths(self): - for i in range(self.columnCount()): + for i in range(self.widget.columnCount()): if i in self.checkbox_columns: - self.setColumnWidth(i, 50) + self.widget.setColumnWidth(i, 50) diff --git a/src/compas_viewer/config.py b/src/compas_viewer/config.py index cc4840757a..aeb17e6cd7 100644 --- a/src/compas_viewer/config.py +++ b/src/compas_viewer/config.py @@ -18,7 +18,6 @@ from compas_viewer.commands import delete_selected_cmd from compas_viewer.commands import deselect_all_cmd from compas_viewer.commands import load_scene_cmd -from compas_viewer.commands import obj_settings_cmd from compas_viewer.commands import pan_view_cmd from compas_viewer.commands import rotate_view_cmd from compas_viewer.commands import save_scene_cmd @@ -257,18 +256,7 @@ 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}]}, - ], - }, + {"type": "ObjectSetting"}, ] ) @@ -431,6 +419,5 @@ class Config(ConfigBase): toggle_toolbar_cmd, zoom_selected_cmd, zoom_view_cmd, - obj_settings_cmd, ] ) diff --git a/src/compas_viewer/ui/container.py b/src/compas_viewer/ui/container.py new file mode 100644 index 0000000000..817fd0dc9e --- /dev/null +++ b/src/compas_viewer/ui/container.py @@ -0,0 +1,37 @@ +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import QLayout + +if TYPE_CHECKING: + from .ui import UI + + +class Container: + def __init__(self, ui: "UI", *args, **kwargs): + super().__init__(*args, **kwargs) + self.ui = ui + self.widget: QLayout = None + self.children = [] + + def add(self, ui_element): + self.children.append(ui_element) + self.widget.addWidget(ui_element.widget) + + def remove(self, ui_element): + self.children.remove(ui_element) + self.widget.removeWidget(ui_element.widget) + + @property + def show(self): + return self.widget.isVisible() + + @show.setter + def show(self, value: bool): + self.widget.setVisible(value) + + def update(self): + self.widget.update() + + # TODO: Avoid double updates + for child in self.children: + child.update() diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 0fe5b52f88..0bcbbe050f 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -6,21 +6,22 @@ from compas_viewer.components import Sceneform from compas_viewer.components.objectsetting import ObjectSetting +from compas_viewer.ui.container import Container if TYPE_CHECKING: from .ui import UI -class SideBarRight: +class SideBarRight(Container): def __init__(self, ui: "UI", show: bool, items: list[dict[str, Callable]]) -> None: - self.ui = ui - self.widget = QSplitter(QtCore.Qt.Orientation.Vertical) + super().__init__(ui) + self.widget: QSplitter = QSplitter(QtCore.Qt.Orientation.Vertical) self.widget.setChildrenCollapsible(True) + self.show = show - self.hide_widget = True self.items = items - def add_items(self) -> None: + def load_items(self) -> None: if not self.items: return @@ -32,43 +33,8 @@ def add_items(self) -> None: if columns is None: raise ValueError("Please setup config for Sceneform") self.sceneform = Sceneform(columns=columns) - self.widget.addWidget(self.sceneform) + self.add(self.sceneform) 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() - - @property - def show(self): - return self.widget.isVisible() - - @show.setter - def show(self, value: bool): - 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) + self.object_setting = ObjectSetting() + self.add(self.object_setting) diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 7f14d951c5..b3dc5e125b 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -45,7 +45,7 @@ def __init__(self, viewer: "Viewer") -> None: show=self.viewer.config.ui.sidedock.show, ) # TODO: find better solution to transient window - self.sidebar.add_items() + self.sidebar.load_items() self.window.widget.setCentralWidget(self.viewport.widget) self.window.widget.addDockWidget(SideDock.locations["left"], self.sidedock.widget) From 404fb4b26fbca5ec3b610f85e6ecea00ac098f50 Mon Sep 17 00:00:00 2001 From: Licini Date: Fri, 4 Jul 2025 19:08:59 +0200 Subject: [PATCH 02/18] ordering --- src/compas_viewer/components/objectsetting.py | 70 +++++++++---------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 236864278b..2f8d9cedca 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -22,6 +22,10 @@ def __init__(self): self.widget.setWidgetResizable(True) self.reset() + @property + def selected(self): + return [obj for obj in self.scene.objects if obj.is_selected] + def reset(self): """Reset the content widget and layout to a clean state.""" self.sub_widgets = {} @@ -34,63 +38,59 @@ def reset(self): self.settings_layout.setContentsMargins(0, 0, 0, 0) self.scroll_layout.addLayout(self.settings_layout) - @property - def selected(self): - return [obj for obj in self.scene.objects if obj.is_selected] - - def add_row(self, attr_name: str, widget: QWidget = None) -> None: - """Create a setting row with label and widget, then add it to the layout.""" - row_layout = QHBoxLayout() - row_layout.setSpacing(0) - row_layout.setContentsMargins(0, 0, 0, 0) - - label = QLabel(attr_name) - row_layout.addWidget(label) - - if widget: - row_layout.addWidget(widget) - self.sub_widgets[attr_name] = widget + def update(self): + """Update the layout with the latest object settings.""" + self.reset() - self.settings_layout.addLayout(row_layout) + if len(self.selected) == 1: + self._populate() + self._add_event_listeners() + elif len(self.selected) > 1: + self._add_row("Multiple objects selected") + else: + self._add_row("No object selected") - def populate(self) -> None: + def _populate(self) -> None: """Populate the layout with the settings of the selected object.""" obj = self.selected[0] if not obj: return - self.add_row("name", TextEdit(text=str(obj.name))) + self._add_row("name", TextEdit(text=str(obj.name))) if hasattr(obj, "pointcolor") and obj.pointcolor is not None: - self.add_row("pointcolor", ColorDialog(obj=obj, attr="pointcolor")) + self._add_row("pointcolor", ColorDialog(obj=obj, attr="pointcolor")) if hasattr(obj, "linecolor") and obj.linecolor is not None: - self.add_row("linecolor", ColorDialog(obj=obj, attr="linecolor")) + self._add_row("linecolor", ColorDialog(obj=obj, attr="linecolor")) if hasattr(obj, "facecolor") and obj.facecolor is not None: - self.add_row("facecolor", ColorDialog(obj=obj, attr="facecolor")) + self._add_row("facecolor", ColorDialog(obj=obj, attr="facecolor")) if hasattr(obj, "linewidth") and obj.linewidth is not None: - self.add_row("linewidth", DoubleEdit(title=None, value=obj.linewidth, min_val=0.0, max_val=10.0)) + self._add_row("linewidth", DoubleEdit(title=None, value=obj.linewidth, min_val=0.0, max_val=10.0)) if hasattr(obj, "pointsize") and obj.pointsize is not None: - self.add_row("pointsize", DoubleEdit(title=None, value=obj.pointsize, min_val=0.0, max_val=10.0)) + self._add_row("pointsize", DoubleEdit(title=None, value=obj.pointsize, min_val=0.0, max_val=10.0)) if hasattr(obj, "opacity") and obj.opacity is not None: - self.add_row("opacity", DoubleEdit(title=None, value=obj.opacity, min_val=0.0, max_val=1.0)) + self._add_row("opacity", DoubleEdit(title=None, value=obj.opacity, min_val=0.0, max_val=1.0)) - def update(self): - """Update the layout with the latest object settings.""" - self.reset() + def _add_row(self, attr_name: str, widget: QWidget = None) -> None: + """Create a setting row with label and widget, then add it to the layout.""" + row_layout = QHBoxLayout() + row_layout.setSpacing(0) + row_layout.setContentsMargins(0, 0, 0, 0) - if len(self.selected) == 1: - self.populate() - self._add_event_listeners() - elif len(self.selected) > 1: - self.add_row("Multiple objects selected") - else: - self.add_row("No object selected") + label = QLabel(attr_name) + row_layout.addWidget(label) + + if widget: + row_layout.addWidget(widget) + self.sub_widgets[attr_name] = widget + + self.settings_layout.addLayout(row_layout) def _add_event_listeners(self): """Add event listeners to the sub widgets.""" From 3e109f056ae62c76c79dfe248751dff687dea76b Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 17:34:13 +0200 Subject: [PATCH 03/18] restructure text edit and number edit --- .../components/boundcomponent.py | 95 +++++++++++++++++++ src/compas_viewer/components/double_edit.py | 69 -------------- src/compas_viewer/components/layout.py | 4 +- src/compas_viewer/components/numberedit.py | 95 +++++++++++++++++++ src/compas_viewer/components/objectsetting.py | 78 ++++++--------- src/compas_viewer/components/sceneform.py | 4 +- src/compas_viewer/components/textedit.py | 76 +++++++++++++-- 7 files changed, 290 insertions(+), 131 deletions(-) create mode 100644 src/compas_viewer/components/boundcomponent.py delete mode 100644 src/compas_viewer/components/double_edit.py create mode 100644 src/compas_viewer/components/numberedit.py diff --git a/src/compas_viewer/components/boundcomponent.py b/src/compas_viewer/components/boundcomponent.py new file mode 100644 index 0000000000..9d6ee46433 --- /dev/null +++ b/src/compas_viewer/components/boundcomponent.py @@ -0,0 +1,95 @@ +from typing import Callable +from typing import Union +from typing import Any +from .component import Component + + +class BoundComponent(Component): + """ + Base class for components that are bound to object attributes. + + This class provides common functionality for UI components that need to be bound + to an attribute of an object or dictionary. It handles getting and setting values + from the bound attribute and provides a callback mechanism for when values change. + + Parameters + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute to be bound. + attr : str + The name of the attribute/key to be bound. + callback : Callable[[Component, float], None] + A function to call when the value changes. Receives the component and new value. + + Attributes + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute being bound. + attr : str + The name of the attribute/key being bound. + callback : Callable[[Component, float], None] + The callback function to call when the value changes. + + Example + ------- + >>> class MyObject: + ... def __init__(self): + ... self.value = 10.0 + >>> def my_callback(component, value): + ... print(f"Value changed to: {value}") + >>> obj = MyObject() + >>> component = BoundComponent(obj, "value", my_callback) + >>> component.set_attr(20.0) + >>> print(component.get_attr()) # prints 20.0 + """ + + def __init__(self, obj: Union[object, dict], attr: str, callback: Callable[[Component, float], None]): + super().__init__() + + self.obj = obj + self.attr = attr + self.callback = callback + + def get_attr(self): + """ + Get the current value of the bound attribute. + + Returns + ------- + float + The current value of the attribute. + """ + if isinstance(self.obj, dict): + return self.obj[self.attr] + else: + return getattr(self.obj, self.attr) + + def set_attr(self, value: float): + """ + Set the value of the bound attribute. + + Parameters + ---------- + value : float + The new value to set. + """ + if isinstance(self.obj, dict): + self.obj[self.attr] = value + else: + setattr(self.obj, self.attr, value) + + def on_value_changed(self, value: Any): + """ + Handle value changes for the bound attribute. + + This method is called when the component's value changes. It updates the bound + attribute and calls the callback function if one was provided. + + Parameters + ---------- + value : float + The new value to set. + """ + self.set_attr(value) + if self.callback is not None: + self.callback(self, value) diff --git a/src/compas_viewer/components/double_edit.py b/src/compas_viewer/components/double_edit.py deleted file mode 100644 index 1a99979eba..0000000000 --- a/src/compas_viewer/components/double_edit.py +++ /dev/null @@ -1,69 +0,0 @@ -import sys - -from PySide6 import QtWidgets - - -class DoubleEdit(QtWidgets.QWidget): - """ - A custom QWidget for editing floating-point numbers with a label and a double spin box. - - Parameters - ---------- - title : str, optional - The label text to be displayed next to the spin box. Defaults to None. - value : float, optional - The initial value of the spin box. Defaults to None. - min_val : float, optional - The minimum value allowed in the spin box. Defaults to the smallest float value if not specified. - max_val : float, optional - The maximum value allowed in the spin box. Defaults to the largest float value if not specified. - - Attributes - ---------- - layout : QHBoxLayout - The horizontal layout containing the label and the spin box. - label : QLabel - The label displaying the title. - spinbox : QDoubleSpinBox - The double spin box for editing the floating-point number. - - Example - ------- - >>> widget = DoubleEdit(title="X", value=0.0, min_val=-10.0, max_val=10.0) - >>> widget.show() - """ - - def __init__( - self, - title: str = None, - value: float = None, - min_val: float = None, - max_val: float = None, - ): - super().__init__() - - if min_val is None: - min_val = -sys.float_info.max - if max_val is None: - max_val = sys.float_info.max - - 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) - self.layout.addWidget(self.label) - 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 - - self._default_layout = DefaultLayout(QtWidgets.QHBoxLayout()) - return self._default_layout diff --git a/src/compas_viewer/components/layout.py b/src/compas_viewer/components/layout.py index 6b29f10bc2..7576ae7378 100644 --- a/src/compas_viewer/components/layout.py +++ b/src/compas_viewer/components/layout.py @@ -10,7 +10,7 @@ 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.numberedit import NumberEdit from compas_viewer.components.label import LabelWidget from compas_viewer.components.textedit import TextEdit @@ -144,7 +144,7 @@ def set_layout(self, items: list[dict], obj: Any) -> None: if type == "double_edit": value = action(obj) - widget = DoubleEdit(title=sub_title, value=value, min_val=min_val, max_val=max_val) + widget = NumberEdit(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") diff --git a/src/compas_viewer/components/numberedit.py b/src/compas_viewer/components/numberedit.py new file mode 100644 index 0000000000..3005fc4e61 --- /dev/null +++ b/src/compas_viewer/components/numberedit.py @@ -0,0 +1,95 @@ +from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QDoubleSpinBox +from typing import Callable +from typing import Union +from .component import Component +from .boundcomponent import BoundComponent + + +class NumberEdit(BoundComponent): + """ + This component creates a labeled number spin box that can be bound to an object's attribute + (either a dictionary key or object attribute). When the value changes, it automatically + updates the bound attribute and optionally calls a callback function. + + Parameters + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute to be edited. + attr : str + The name of the attribute/key to be edited. + title : str, optional + The label text to be displayed next to the spin box. If None, uses the attr name. + min_val : float, optional + The minimum value allowed in the spin box. If None, uses the default minimum. + max_val : float, optional + The maximum value allowed in the spin box. If None, uses the default maximum. + step : float, optional + The step size for the spin box. Defaults to 0.1. + decimals : int, optional + The number of decimal places to display. Defaults to 1. + callback : Callable[[Component, float], None], optional + A function to call when the value changes. Receives the component and new value. + + Attributes + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute being edited. + attr : str + The name of the attribute/key being edited. + callback : Callable[[Component, float], None] or None + The callback function to call when the value changes. + widget : QWidget + The main widget containing the layout. + layout : QHBoxLayout + The horizontal layout containing the label and the spin box. + label : QLabel + The label displaying the title. + spinbox : QDoubleSpinBox + The double spin box for editing the floating-point number. + + Example + ------- + >>> class MyObject: + ... def __init__(self): + ... self.x = 5.0 + >>> obj = MyObject() + >>> component = NumberEdit(obj, "x", title="X Coordinate", min_val=0.0, max_val=10.0) + """ + + def __init__( + self, + obj: Union[object, dict], + attr: str, + title: str = None, + min_val: float = None, + max_val: float = None, + step: float = 0.1, + decimals: int = 1, + callback: Callable[[Component, float], None] = None, + ): + super().__init__(obj, attr, callback=callback) + + self.widget = QWidget() + self.layout = QHBoxLayout() + + title = title if title is not None else attr + self.label = QLabel(title) + self.spinbox = QDoubleSpinBox() + self.spinbox.setDecimals(decimals) + self.spinbox.setSingleStep(step) + self.spinbox.setMaximumSize(85, 25) + + self.spinbox.setValue(self.get_attr()) + + if min_val is not None: + self.spinbox.setMinimum(min_val) + if max_val is not None: + self.spinbox.setMaximum(max_val) + + self.layout.addWidget(self.label) + self.layout.addWidget(self.spinbox) + self.widget.setLayout(self.layout) + self.spinbox.valueChanged.connect(self.on_value_changed) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 2f8d9cedca..93e938f813 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -7,7 +7,7 @@ from compas_viewer.components.color import ColorDialog from compas_viewer.components.component import Component -from compas_viewer.components.double_edit import DoubleEdit +from compas_viewer.components.numberedit import NumberEdit from compas_viewer.components.textedit import TextEdit @@ -28,7 +28,7 @@ def selected(self): def reset(self): """Reset the content widget and layout to a clean state.""" - self.sub_widgets = {} + self.children = [] self.scroll_content = QWidget() self.scroll_layout = QVBoxLayout(self.scroll_content) self.scroll_layout.setAlignment(Qt.AlignTop) @@ -43,39 +43,50 @@ def update(self): self.reset() if len(self.selected) == 1: - self._populate() - self._add_event_listeners() + self._populate(self.selected[0]) + # self._add_event_listeners() elif len(self.selected) > 1: self._add_row("Multiple objects selected") else: self._add_row("No object selected") - def _populate(self) -> None: + def _populate(self, obj: object) -> None: """Populate the layout with the settings of the selected object.""" - obj = self.selected[0] - if not obj: - return + def update_obj(_, value): + obj.update() + self.viewer.renderer.update() + self.viewer.ui.sidebar.sceneform.update(refresh=True) + - self._add_row("name", TextEdit(text=str(obj.name))) + # if hasattr(obj, "pointcolor") and obj.pointcolor is not None: + # self._add_row("pointcolor", ColorDialog(obj=obj, attr="pointcolor")) - if hasattr(obj, "pointcolor") and obj.pointcolor is not None: - self._add_row("pointcolor", ColorDialog(obj=obj, attr="pointcolor")) + # if hasattr(obj, "linecolor") and obj.linecolor is not None: + # self._add_row("linecolor", ColorDialog(obj=obj, attr="linecolor")) - if hasattr(obj, "linecolor") and obj.linecolor is not None: - self._add_row("linecolor", ColorDialog(obj=obj, attr="linecolor")) + # if hasattr(obj, "facecolor") and obj.facecolor is not None: + # self._add_row("facecolor", ColorDialog(obj=obj, attr="facecolor")) - if hasattr(obj, "facecolor") and obj.facecolor is not None: - self._add_row("facecolor", ColorDialog(obj=obj, attr="facecolor")) + if hasattr(obj, "name") and obj.name is not None: + name_edit = TextEdit(obj, "name", callback=update_obj) + self.add(name_edit) if hasattr(obj, "linewidth") and obj.linewidth is not None: - self._add_row("linewidth", DoubleEdit(title=None, value=obj.linewidth, min_val=0.0, max_val=10.0)) + linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, callback=update_obj) + self.add(linewidth_edit) if hasattr(obj, "pointsize") and obj.pointsize is not None: - self._add_row("pointsize", DoubleEdit(title=None, value=obj.pointsize, min_val=0.0, max_val=10.0)) + pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, callback=update_obj) + self.add(pointsize_edit) if hasattr(obj, "opacity") and obj.opacity is not None: - self._add_row("opacity", DoubleEdit(title=None, value=obj.opacity, min_val=0.0, max_val=1.0)) + opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, callback=update_obj) + self.add(opacity_edit) + + def add(self, component: Component) -> None: + self.settings_layout.addWidget(component.widget) + self.children.append(component) def _add_row(self, attr_name: str, widget: QWidget = None) -> None: """Create a setting row with label and widget, then add it to the layout.""" @@ -88,35 +99,6 @@ def _add_row(self, attr_name: str, widget: QWidget = None) -> None: if widget: row_layout.addWidget(widget) - self.sub_widgets[attr_name] = widget + self.children.append(widget) self.settings_layout.addLayout(row_layout) - - def _add_event_listeners(self): - """Add event listeners to the sub widgets.""" - - def _update_obj(): - if len(self.selected) == 0: - return - - obj = self.selected[0] - - for attr_name, widget in self.sub_widgets.items(): - if not hasattr(obj, attr_name): - continue - if isinstance(widget, TextEdit): - value = widget.text_edit.toPlainText() - elif isinstance(widget, DoubleEdit): - value = widget.spinbox.value() - else: - continue - - setattr(obj, attr_name, value) - - obj.update() - - for widget in self.sub_widgets.values(): - if isinstance(widget, TextEdit): - widget.text_edit.textChanged.connect(_update_obj) - elif isinstance(widget, DoubleEdit): - widget.spinbox.valueChanged.connect(_update_obj) diff --git a/src/compas_viewer/components/sceneform.py b/src/compas_viewer/components/sceneform.py index 2879366d5b..69d79f16fa 100644 --- a/src/compas_viewer/components/sceneform.py +++ b/src/compas_viewer/components/sceneform.py @@ -66,8 +66,8 @@ def __init__( def scene(self): return self.viewer.scene - def update(self): - if list(self.scene.objects) == self._sceneobjects: + def update(self, refresh: bool = False): + if list(self.scene.objects) == self._sceneobjects and not refresh: for node in self.scene.traverse("breadthfirst"): widget = node.attributes.get("widget") if widget: diff --git a/src/compas_viewer/components/textedit.py b/src/compas_viewer/components/textedit.py index 460e1a385e..1b1ec89523 100644 --- a/src/compas_viewer/components/textedit.py +++ b/src/compas_viewer/components/textedit.py @@ -3,28 +3,84 @@ from PySide6.QtWidgets import QSizePolicy from PySide6.QtWidgets import QTextEdit from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QLabel +from typing import Callable +from typing import Union +from .component import Component +from .boundcomponent import BoundComponent -class TextEdit(QWidget): +class TextEdit(BoundComponent): """ - A customizable QTextEdit widget for Qt applications, supporting text alignment and font size adjustments. + This component creates a labeled text edit widget that can be bound to an object's attribute + (either a dictionary key or object attribute). When the text changes, it automatically + updates the bound attribute and optionally calls a callback function. + + Parameters + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute to be edited. + attr : str + The name of the attribute/key to be edited. + title : str, optional + The label text to be displayed next to the text edit. If None, uses the attr name. + callback : Callable[[Component, str], None], optional + A function to call when the text changes. Receives the component and new text value. + + Attributes + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute being edited. + attr : str + The name of the attribute/key being edited. + callback : Callable[[Component, str], None] or None + The callback function to call when the text changes. + widget : QWidget + The main widget containing the layout. + layout : QHBoxLayout + The horizontal layout containing the label and the text edit. + label : QLabel + The label displaying the title. + text_edit : QTextEdit + The text edit widget for editing the text. + + Example + ------- + >>> class MyObject: + ... def __init__(self): + ... self.name = "Hello World" + >>> obj = MyObject() + >>> component = TextEdit(obj, "name", title="Name") """ def __init__( self, - text: str = None, + obj: Union[object, dict], + attr: str, + title: str = None, + callback: Callable[[Component, str], None] = None, ): - super().__init__() + super().__init__(obj, attr, callback=callback) + + self.widget = QWidget() + self.layout = QHBoxLayout() + title = title if title is not None else attr + self.label = QLabel(title) 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 = QHBoxLayout() - self.layout.setSpacing(0) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setAlignment(Qt.AlignCenter) + self.text_edit.setText(str(self.get_attr())) + self.layout.addWidget(self.label) self.layout.addWidget(self.text_edit) - self.setLayout(self.layout) + self.widget.setLayout(self.layout) + + # Connect the text change signal to the callback + self.text_edit.textChanged.connect(self.on_text_changed) + + def on_text_changed(self): + """Handle text change events by updating the bound attribute and calling the callback.""" + new_text = self.text_edit.toPlainText() + self.on_value_changed(new_text) From 38a96080fe50cc10583c614fba3a22d690ecde20 Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 18:30:03 +0200 Subject: [PATCH 04/18] continue --- src/compas_viewer/components/booleantoggle.py | 90 ++++++++ .../components/boundcomponent.py | 2 +- src/compas_viewer/components/color.py | 203 ------------------ src/compas_viewer/components/colorpicker.py | 140 ++++++++++++ src/compas_viewer/components/component.py | 46 +++- src/compas_viewer/components/layout.py | 5 +- src/compas_viewer/components/objectsetting.py | 94 ++++---- src/compas_viewer/ui/container.py | 1 + src/compas_viewer/ui/sidebar.py | 9 +- 9 files changed, 326 insertions(+), 264 deletions(-) create mode 100644 src/compas_viewer/components/booleantoggle.py delete mode 100644 src/compas_viewer/components/color.py create mode 100644 src/compas_viewer/components/colorpicker.py diff --git a/src/compas_viewer/components/booleantoggle.py b/src/compas_viewer/components/booleantoggle.py new file mode 100644 index 0000000000..8ee59d4d06 --- /dev/null +++ b/src/compas_viewer/components/booleantoggle.py @@ -0,0 +1,90 @@ +from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QCheckBox +from typing import Callable +from typing import Union +from .component import Component +from .boundcomponent import BoundComponent + + +class BooleanToggle(BoundComponent): + """ + This component creates a labeled checkbox that can be bound to an object's boolean attribute + (either a dictionary key or object attribute). When the checkbox state changes, it automatically + updates the bound attribute and optionally calls a callback function. + + Parameters + ---------- + obj : Union[object, dict] + The object or dictionary containing the boolean attribute to be edited. + attr : str + The name of the attribute/key to be edited. + title : str, optional + The label text to be displayed next to the checkbox. If None, uses the attr name. + callback : Callable[[Component, bool], None], optional + A function to call when the checkbox state changes. Receives the component and new boolean value. + + Attributes + ---------- + obj : Union[object, dict] + The object or dictionary containing the boolean attribute being edited. + attr : str + The name of the attribute/key being edited. + callback : Callable[[Component, bool], None] or None + The callback function to call when the checkbox state changes. + widget : QWidget + The main widget containing the layout. + layout : QHBoxLayout + The horizontal layout containing the label and the checkbox. + label : QLabel + The label displaying the title. + checkbox : QCheckBox + The checkbox widget for toggling the boolean value. + + Example + ------- + >>> class MyObject: + ... def __init__(self): + ... self.show_points = True + >>> obj = MyObject() + >>> component = BooleanToggle(obj, "show_points", title="Show Points") + """ + + def __init__( + self, + obj: Union[object, dict], + attr: str, + title: str = None, + callback: Callable[[Component, bool], None] = None, + ): + super().__init__(obj, attr, callback=callback) + + self.widget = QWidget() + self.layout = QHBoxLayout() + + title = title if title is not None else attr + self.label = QLabel(title) + self.checkbox = QCheckBox() + self.checkbox.setMaximumSize(85, 25) + + # Set the initial state from the bound attribute + initial_value = self.get_attr() + if not isinstance(initial_value, bool): + raise ValueError(f"Attribute '{attr}' must be a boolean value, got {type(initial_value)}") + self.checkbox.setChecked(initial_value) + + self.layout.addWidget(self.label) + self.layout.addWidget(self.checkbox) + self.widget.setLayout(self.layout) + + # Connect the checkbox state change signal to the callback + self.checkbox.stateChanged.connect(self.on_state_changed) + + def on_state_changed(self, state): + """Handle checkbox state change events by updating the bound attribute and calling the callback.""" + # Convert Qt checkbox state to boolean + is_checked = state == 2 # Qt.Checked = 2 + self.set_attr(is_checked) + if self.callback: + self.callback(self, is_checked) diff --git a/src/compas_viewer/components/boundcomponent.py b/src/compas_viewer/components/boundcomponent.py index 9d6ee46433..f45bc4c895 100644 --- a/src/compas_viewer/components/boundcomponent.py +++ b/src/compas_viewer/components/boundcomponent.py @@ -42,7 +42,7 @@ class BoundComponent(Component): >>> component.set_attr(20.0) >>> print(component.get_attr()) # prints 20.0 """ - + def __init__(self, obj: Union[object, dict], attr: str, callback: Callable[[Component, float], None]): super().__init__() diff --git a/src/compas_viewer/components/color.py b/src/compas_viewer/components/color.py deleted file mode 100644 index 5bc4b1a602..0000000000 --- a/src/compas_viewer/components/color.py +++ /dev/null @@ -1,203 +0,0 @@ -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. : {}".format(type(default_color))) - 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(update_data=True, update_transform=False) diff --git a/src/compas_viewer/components/colorpicker.py b/src/compas_viewer/components/colorpicker.py new file mode 100644 index 0000000000..645ea07dfc --- /dev/null +++ b/src/compas_viewer/components/colorpicker.py @@ -0,0 +1,140 @@ +from typing import Callable +from typing import Union + +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog +from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel + +from compas.colors import Color +from compas.colors.colordict import ColorDict +from .component import Component +from .boundcomponent import BoundComponent + + +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 ColorPicker(BoundComponent): + """ + This component creates a labeled color picker button that can be bound to an object's color attribute + (either a dictionary key or object attribute). When the color changes, it automatically + updates the bound attribute and optionally calls a callback function. + + Parameters + ---------- + obj : Union[object, dict] + The object or dictionary containing the color attribute to be edited. + attr : str + The name of the attribute/key to be edited. + title : str, optional + The label text to be displayed next to the color picker. If None, uses the attr name. + callback : Callable[[Component, Color], None], optional + A function to call when the color changes. Receives the component and new color value. + + Attributes + ---------- + obj : Union[object, dict] + The object or dictionary containing the color attribute being edited. + attr : str + The name of the attribute/key being edited. + callback : Callable[[Component, Color], None] or None + The callback function to call when the color changes. + widget : QWidget + The main widget containing the layout. + layout : QHBoxLayout + The horizontal layout containing the label and the color picker button. + label : QLabel + The label displaying the title. + color_button : QPushButton + The button that displays the current color and opens the color dialog when clicked. + current_color : QColor + The currently selected color. + + Example + ------- + >>> class MyObject: + ... def __init__(self): + ... self.color = Color(1.0, 0.0, 0.0) # Red color + >>> obj = MyObject() + >>> component = ColorPicker(obj, "color", title="Object Color") + """ + + def __init__( + self, + obj: Union[object, dict], + attr: str, + title: str = None, + callback: Callable[[Component, Color], None] = None, + ): + super().__init__(obj, attr, callback=callback) + + self.widget = QWidget() + self.layout = QHBoxLayout() + + title = title if title is not None else attr + self.label = QLabel(title) + self.color_button = QPushButton() + self.color_button.setMaximumSize(85, 25) + + # Get the initial color from the bound attribute + default_color = self.get_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. : {}".format(type(default_color))) + + default_color = QColor(*remap_rgb(default_color, to_range_one=False)) + self.current_color = default_color + + self.layout.addWidget(self.label) + self.layout.addWidget(self.color_button) + self.widget.setLayout(self.layout) + + self.color_button.clicked.connect(self.open_color_dialog) + self.set_button_color(default_color) + + def open_color_dialog(self): + """Opens a QColorDialog for the user to select a new color.""" + color = QColorDialog.getColor() + + if color.isValid(): + self.change_color(color) + self.set_button_color(color) + + def set_button_color(self, color: QColor): + """Sets the button's background and text to the provided color.""" + luminance = (0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) / 255 + if luminance < 0.5: + self.color_button.setStyleSheet(f"background-color: {color.name()}; color: white;") + else: + self.color_button.setStyleSheet(f"background-color: {color.name()}; color: black;") + self.color_button.setText(color.name()) + self.current_color = color + + def change_color(self, color: QColor): + """Changes the color attribute of the object to the provided color and updates the object.""" + rgb = remap_rgb(color.getRgb())[:-1] # rgba to rgb(0-1) + new_color = Color(*rgb) + self.on_value_changed(new_color) diff --git a/src/compas_viewer/components/component.py b/src/compas_viewer/components/component.py index 53a4001c79..965bced83c 100644 --- a/src/compas_viewer/components/component.py +++ b/src/compas_viewer/components/component.py @@ -1,11 +1,51 @@ from PySide6.QtWidgets import QWidget - +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QScrollArea +from PySide6.QtCore import Qt from compas_viewer.base import Base class Component(Base): - def __init__(self): - self.widget = QWidget() + def __init__(self, scrollable=False): + super().__init__() + self.scrollable = scrollable + + # Create widgets once in init + if self.scrollable: + self.widget = QScrollArea() + self.widget.setWidgetResizable(True) + self.scroll_content = QWidget() + self.scroll_layout = QVBoxLayout(self.scroll_content) + self.scroll_layout.setAlignment(Qt.AlignTop) + self.widget.setWidget(self.scroll_content) + self.layout = QVBoxLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + self.scroll_layout.addLayout(self.layout) + else: + self.widget = QWidget() + self.layout = QVBoxLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + self.widget.setLayout(self.layout) def update(self): self.widget.update() + + def add(self, component: "Component") -> None: + self.layout.addWidget(component.widget) + self.children.append(component) + + def remove(self, component: "Component") -> None: + self.layout.removeWidget(component.widget) + self.children.remove(component) + + def reset(self): + # Clear existing children + self.children = [] + + # Clear the layout without recreating widgets + while self.layout.count(): + child = self.layout.takeAt(0) + if child.widget(): + child.widget().setParent(None) diff --git a/src/compas_viewer/components/layout.py b/src/compas_viewer/components/layout.py index 7576ae7378..acd5271f4f 100644 --- a/src/compas_viewer/components/layout.py +++ b/src/compas_viewer/components/layout.py @@ -8,8 +8,7 @@ from PySide6.QtWidgets import QLayout from PySide6.QtWidgets import QVBoxLayout -from compas_viewer.components.color import ColorComboBox -from compas_viewer.components.color import ColorDialog +from compas_viewer.components.colorpicker import ColorPicker from compas_viewer.components.numberedit import NumberEdit from compas_viewer.components.label import LabelWidget from compas_viewer.components.textedit import TextEdit @@ -149,7 +148,7 @@ def set_layout(self, items: list[dict], obj: Any) -> None: text = action(obj) widget = LabelWidget(text=text, alignment="center") elif type == "color_combobox": - widget = ColorComboBox(obj=obj, attr=attr) + widget = ColorPicker(obj=obj, attr=attr) elif type == "text_edit": text = str(action(obj)) widget = TextEdit(text=text) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 93e938f813..c4c6fd57c1 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -5,11 +5,14 @@ from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget -from compas_viewer.components.color import ColorDialog +from compas_viewer.components.booleantoggle import BooleanToggle +from compas_viewer.components.colorpicker import ColorPicker from compas_viewer.components.component import Component from compas_viewer.components.numberedit import NumberEdit from compas_viewer.components.textedit import TextEdit +from compas_viewer.scene import ViewerSceneObject + class ObjectSetting(Component): """ @@ -17,88 +20,73 @@ class ObjectSetting(Component): """ def __init__(self): - super().__init__() - self.widget = QScrollArea() - self.widget.setWidgetResizable(True) - self.reset() + super().__init__(scrollable=True) @property def selected(self): return [obj for obj in self.scene.objects if obj.is_selected] - def reset(self): - """Reset the content widget and layout to a clean state.""" - self.children = [] - self.scroll_content = QWidget() - self.scroll_layout = QVBoxLayout(self.scroll_content) - self.scroll_layout.setAlignment(Qt.AlignTop) - self.widget.setWidget(self.scroll_content) - self.settings_layout = QVBoxLayout() - self.settings_layout.setSpacing(0) - self.settings_layout.setContentsMargins(0, 0, 0, 0) - self.scroll_layout.addLayout(self.settings_layout) - def update(self): """Update the layout with the latest object settings.""" self.reset() if len(self.selected) == 1: - self._populate(self.selected[0]) - # self._add_event_listeners() + self.populate(self.selected[0]) elif len(self.selected) > 1: - self._add_row("Multiple objects selected") + self.add_label("Multiple objects selected") else: - self._add_row("No object selected") + self.add_label("No object selected") - def _populate(self, obj: object) -> None: + def populate(self, obj: ViewerSceneObject) -> None: """Populate the layout with the settings of the selected object.""" - def update_obj(_, value): + def update_obj_settings(*arg): obj.update() self.viewer.renderer.update() + + def update_obj_color(*arg): + obj.update(update_data=True) + self.viewer.renderer.update() + + def update_sceneform(*arg): self.viewer.ui.sidebar.sceneform.update(refresh=True) + if hasattr(obj, "name") and obj.name is not None: + name_edit = TextEdit(obj, "name", callback=update_sceneform) + self.add(name_edit) + + if hasattr(obj, "show_points") and obj.show_points is not None: + self.add(BooleanToggle(obj=obj, attr="show_points", callback=update_obj_settings)) - # if hasattr(obj, "pointcolor") and obj.pointcolor is not None: - # self._add_row("pointcolor", ColorDialog(obj=obj, attr="pointcolor")) + if hasattr(obj, "show_lines") and obj.show_lines is not None: + self.add(BooleanToggle(obj=obj, attr="show_lines", callback=update_obj_settings)) - # if hasattr(obj, "linecolor") and obj.linecolor is not None: - # self._add_row("linecolor", ColorDialog(obj=obj, attr="linecolor")) + if hasattr(obj, "show_faces") and obj.show_faces is not None: + self.add(BooleanToggle(obj=obj, attr="show_faces", callback=update_obj_settings)) - # if hasattr(obj, "facecolor") and obj.facecolor is not None: - # self._add_row("facecolor", ColorDialog(obj=obj, attr="facecolor")) + if hasattr(obj, "pointcolor") and obj.pointcolor is not None: + self.add(ColorPicker(obj=obj, attr="pointcolor", callback=update_obj_color)) - if hasattr(obj, "name") and obj.name is not None: - name_edit = TextEdit(obj, "name", callback=update_obj) - self.add(name_edit) + if hasattr(obj, "linecolor") and obj.linecolor is not None: + self.add(ColorPicker(obj=obj, attr="linecolor", callback=update_obj_color)) + + if hasattr(obj, "facecolor") and obj.facecolor is not None: + self.add(ColorPicker(obj=obj, attr="facecolor", callback=update_obj_color)) if hasattr(obj, "linewidth") and obj.linewidth is not None: - linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, callback=update_obj) + linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, callback=update_obj_settings) self.add(linewidth_edit) if hasattr(obj, "pointsize") and obj.pointsize is not None: - pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, callback=update_obj) + pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, callback=update_obj_settings) self.add(pointsize_edit) if hasattr(obj, "opacity") and obj.opacity is not None: - opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, callback=update_obj) + opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, callback=update_obj_settings) self.add(opacity_edit) - def add(self, component: Component) -> None: - self.settings_layout.addWidget(component.widget) - self.children.append(component) - - def _add_row(self, attr_name: str, widget: QWidget = None) -> None: - """Create a setting row with label and widget, then add it to the layout.""" - row_layout = QHBoxLayout() - row_layout.setSpacing(0) - row_layout.setContentsMargins(0, 0, 0, 0) - - label = QLabel(attr_name) - row_layout.addWidget(label) - - if widget: - row_layout.addWidget(widget) - self.children.append(widget) - - self.settings_layout.addLayout(row_layout) + def add_label(self, text: str) -> None: + label = QLabel(text) + label.setAlignment(Qt.AlignCenter) + label.setStyleSheet("color: gray; font-style: italic; padding: 10px;") + self.layout.addWidget(label) diff --git a/src/compas_viewer/ui/container.py b/src/compas_viewer/ui/container.py index 817fd0dc9e..13f6bf2b5c 100644 --- a/src/compas_viewer/ui/container.py +++ b/src/compas_viewer/ui/container.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING from PySide6.QtWidgets import QLayout +from PySide6.QtWidgets import QSizePolicy if TYPE_CHECKING: from .ui import UI diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 0bcbbe050f..63b1d3a528 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -35,6 +35,13 @@ def load_items(self) -> None: self.sceneform = Sceneform(columns=columns) self.add(self.sceneform) - elif itemtype == "ObjectSetting": + if itemtype == "ObjectSetting": self.object_setting = ObjectSetting() self.add(self.object_setting) + + # Set equal sizes for all children + child_count = self.widget.count() + if child_count > 0: + # Set each child to have equal size (1000 is arbitrary but proportional) + equal_sizes = [1000] * child_count + self.widget.setSizes(equal_sizes) From a85fe2d9ccb9acbaab672e247c1fc4df4d0bd305 Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 19:23:36 +0200 Subject: [PATCH 05/18] container class --- src/compas_viewer/components/component.py | 53 +++-------- src/compas_viewer/components/container.py | 88 +++++++++++++++++++ src/compas_viewer/components/objectsetting.py | 63 ++++++------- 3 files changed, 128 insertions(+), 76 deletions(-) create mode 100644 src/compas_viewer/components/container.py diff --git a/src/compas_viewer/components/component.py b/src/compas_viewer/components/component.py index 965bced83c..8d459b2c74 100644 --- a/src/compas_viewer/components/component.py +++ b/src/compas_viewer/components/component.py @@ -1,51 +1,20 @@ from PySide6.QtWidgets import QWidget -from PySide6.QtWidgets import QVBoxLayout -from PySide6.QtWidgets import QScrollArea -from PySide6.QtCore import Qt from compas_viewer.base import Base class Component(Base): - def __init__(self, scrollable=False): - super().__init__() - self.scrollable = scrollable - - # Create widgets once in init - if self.scrollable: - self.widget = QScrollArea() - self.widget.setWidgetResizable(True) - self.scroll_content = QWidget() - self.scroll_layout = QVBoxLayout(self.scroll_content) - self.scroll_layout.setAlignment(Qt.AlignTop) - self.widget.setWidget(self.scroll_content) - self.layout = QVBoxLayout() - self.layout.setSpacing(0) - self.layout.setContentsMargins(0, 0, 0, 0) - self.scroll_layout.addLayout(self.layout) - else: - self.widget = QWidget() - self.layout = QVBoxLayout() - self.layout.setSpacing(0) - self.layout.setContentsMargins(0, 0, 0, 0) - self.widget.setLayout(self.layout) - - def update(self): - self.widget.update() + """A base class for all UI components in the viewer. - def add(self, component: "Component") -> None: - self.layout.addWidget(component.widget) - self.children.append(component) + Attributes + ---------- + widget : QWidget + The main widget that contains all child components. - def remove(self, component: "Component") -> None: - self.layout.removeWidget(component.widget) - self.children.remove(component) + """ - def reset(self): - # Clear existing children - self.children = [] + def __init__(self): + super().__init__() + self.widget = QWidget() - # Clear the layout without recreating widgets - while self.layout.count(): - child = self.layout.takeAt(0) - if child.widget(): - child.widget().setParent(None) + def update(self): + self.widget.update() diff --git a/src/compas_viewer/components/container.py b/src/compas_viewer/components/container.py new file mode 100644 index 0000000000..e3b3005626 --- /dev/null +++ b/src/compas_viewer/components/container.py @@ -0,0 +1,88 @@ +from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QScrollArea +from PySide6.QtWidgets import QLabel +from PySide6.QtCore import Qt +from .component import Component + + +class Container(Component): + """A container component that can hold other components in a vertical layout. + + The Container class provides a flexible way to organize and display multiple + components in a vertical arrangement. It supports both scrollable and non-scrollable + modes, making it suitable for various UI layouts. + + Parameters + ---------- + scrollable : bool, optional + If True, creates a scrollable container using QScrollArea. + If False, creates a standard container with QWidget. + Default is False. + + Attributes + ---------- + scrollable : bool + Whether the container is scrollable. + widget : QWidget or QScrollArea + The main widget that contains all child components. + layout : QVBoxLayout + The vertical layout that arranges child components. + + Examples + -------- + >>> # Create a simple container + >>> container = Container() + >>> container.add(some_component) + + >>> # Create a scrollable container + >>> scrollable_container = Container(scrollable=True) + >>> scrollable_container.add(component1) + >>> scrollable_container.add(component2) + """ + + def __init__(self, scrollable=False): + self.scrollable = scrollable + if self.scrollable: + self.widget = QScrollArea() + self.widget.setWidgetResizable(True) + self._scroll_content = QWidget() + self._scroll_layout = QVBoxLayout(self._scroll_content) + self._scroll_layout.setAlignment(Qt.AlignTop) + self.widget.setWidget(self._scroll_content) + self.layout = QVBoxLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + self._scroll_layout.addLayout(self.layout) + else: + self.widget = QWidget() + self.layout = QVBoxLayout() + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + self.widget.setLayout(self.layout) + + def add(self, component: "Component") -> None: + """Add a component to the container.""" + self.layout.addWidget(component.widget) + self.children.append(component) + + def remove(self, component: "Component") -> None: + """Remove a component from the container.""" + self.layout.removeWidget(component.widget) + self.children.remove(component) + + def reset(self): + """Reset the container to its initial state.""" + self.children = [] + while self.layout.count(): + child = self.layout.takeAt(0) + if child.widget(): + child.widget().setParent(None) + + def display_text(self, text: str) -> None: + """Display a text when there is nothing else to show.""" + self.reset() + label = QLabel(text) + label.setAlignment(Qt.AlignCenter) + label.setStyleSheet("color: gray; font-style: italic; padding: 10px;") + self.layout.addWidget(label) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index c4c6fd57c1..6f194e806d 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -5,6 +5,7 @@ from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget +from .container import Container from compas_viewer.components.booleantoggle import BooleanToggle from compas_viewer.components.colorpicker import ColorPicker from compas_viewer.components.component import Component @@ -14,7 +15,7 @@ from compas_viewer.scene import ViewerSceneObject -class ObjectSetting(Component): +class ObjectSetting(Container): """ A component to manage the settings of objects in the viewer. """ @@ -28,65 +29,59 @@ def selected(self): def update(self): """Update the layout with the latest object settings.""" - self.reset() - if len(self.selected) == 1: self.populate(self.selected[0]) elif len(self.selected) > 1: - self.add_label("Multiple objects selected") + self.display_text("Multiple objects selected") else: - self.add_label("No object selected") + self.display_text("No object selected") def populate(self, obj: ViewerSceneObject) -> None: """Populate the layout with the settings of the selected object.""" - def update_obj_settings(*arg): + self.reset() + + def _update_obj_settings(*arg): obj.update() self.viewer.renderer.update() - def update_obj_color(*arg): + def _update_obj_settings(*arg): obj.update(update_data=True) self.viewer.renderer.update() - def update_sceneform(*arg): + def _update_sceneform(*arg): self.viewer.ui.sidebar.sceneform.update(refresh=True) - if hasattr(obj, "name") and obj.name is not None: - name_edit = TextEdit(obj, "name", callback=update_sceneform) + if hasattr(obj, "name"): + name_edit = TextEdit(obj, "name", callback=_update_sceneform) self.add(name_edit) - if hasattr(obj, "show_points") and obj.show_points is not None: - self.add(BooleanToggle(obj=obj, attr="show_points", callback=update_obj_settings)) + if hasattr(obj, "show_points"): + self.add(BooleanToggle(obj=obj, attr="show_points", callback=_update_obj_settings)) - if hasattr(obj, "show_lines") and obj.show_lines is not None: - self.add(BooleanToggle(obj=obj, attr="show_lines", callback=update_obj_settings)) + if hasattr(obj, "show_lines"): + self.add(BooleanToggle(obj=obj, attr="show_lines", callback=_update_obj_settings)) - if hasattr(obj, "show_faces") and obj.show_faces is not None: - self.add(BooleanToggle(obj=obj, attr="show_faces", callback=update_obj_settings)) + if hasattr(obj, "show_faces"): + self.add(BooleanToggle(obj=obj, attr="show_faces", callback=_update_obj_settings)) - if hasattr(obj, "pointcolor") and obj.pointcolor is not None: - self.add(ColorPicker(obj=obj, attr="pointcolor", callback=update_obj_color)) + if hasattr(obj, "pointcolor"): + self.add(ColorPicker(obj=obj, attr="pointcolor", callback=_update_obj_settings)) - if hasattr(obj, "linecolor") and obj.linecolor is not None: - self.add(ColorPicker(obj=obj, attr="linecolor", callback=update_obj_color)) + if hasattr(obj, "linecolor"): + self.add(ColorPicker(obj=obj, attr="linecolor", callback=_update_obj_settings)) - if hasattr(obj, "facecolor") and obj.facecolor is not None: - self.add(ColorPicker(obj=obj, attr="facecolor", callback=update_obj_color)) + if hasattr(obj, "facecolor"): + self.add(ColorPicker(obj=obj, attr="facecolor", callback=_update_obj_settings)) - if hasattr(obj, "linewidth") and obj.linewidth is not None: - linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, callback=update_obj_settings) + if hasattr(obj, "linewidth"): + linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, callback=_update_obj_settings) self.add(linewidth_edit) - if hasattr(obj, "pointsize") and obj.pointsize is not None: - pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, callback=update_obj_settings) + if hasattr(obj, "pointsize"): + pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, callback=_update_obj_settings) self.add(pointsize_edit) - if hasattr(obj, "opacity") and obj.opacity is not None: - opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, callback=update_obj_settings) + if hasattr(obj, "opacity"): + opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, callback=_update_obj_settings) self.add(opacity_edit) - - def add_label(self, text: str) -> None: - label = QLabel(text) - label.setAlignment(Qt.AlignCenter) - label.setStyleSheet("color: gray; font-style: italic; padding: 10px;") - self.layout.addWidget(label) From 632d4940b9e8ddc785a5ead6f560225eed12d70a Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 20:13:15 +0200 Subject: [PATCH 06/18] make sidebar a container --- src/compas_viewer/components/component.py | 10 +++ src/compas_viewer/components/container.py | 85 ++++++++++++++----- src/compas_viewer/components/objectsetting.py | 18 ++-- src/compas_viewer/components/sceneform.py | 4 - src/compas_viewer/ui/container.py | 38 --------- src/compas_viewer/ui/sidebar.py | 43 +++++----- src/compas_viewer/ui/ui.py | 8 +- 7 files changed, 100 insertions(+), 106 deletions(-) delete mode 100644 src/compas_viewer/ui/container.py diff --git a/src/compas_viewer/components/component.py b/src/compas_viewer/components/component.py index 8d459b2c74..35c580e5b6 100644 --- a/src/compas_viewer/components/component.py +++ b/src/compas_viewer/components/component.py @@ -15,6 +15,16 @@ class Component(Base): def __init__(self): super().__init__() self.widget = QWidget() + self._show = True def update(self): self.widget.update() + + @property + def show(self): + return self._show + + @show.setter + def show(self, value: bool): + self._show = value + self.widget.setVisible(value) diff --git a/src/compas_viewer/components/container.py b/src/compas_viewer/components/container.py index e3b3005626..3235bb8ef1 100644 --- a/src/compas_viewer/components/container.py +++ b/src/compas_viewer/components/container.py @@ -1,6 +1,7 @@ from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QScrollArea +from PySide6.QtWidgets import QSplitter from PySide6.QtWidgets import QLabel from PySide6.QtCore import Qt from .component import Component @@ -10,24 +11,26 @@ class Container(Component): """A container component that can hold other components in a vertical layout. The Container class provides a flexible way to organize and display multiple - components in a vertical arrangement. It supports both scrollable and non-scrollable - modes, making it suitable for various UI layouts. + components in a vertical arrangement. It supports scrollable, splitter, and standard + container modes, making it suitable for various UI layouts. Parameters ---------- - scrollable : bool, optional - If True, creates a scrollable container using QScrollArea. - If False, creates a standard container with QWidget. - Default is False. + container_type : str, optional + Type of container to create. Options are: + - None or "standard": Creates a standard container with QWidget + - "scrollable": Creates a scrollable container using QScrollArea + - "splitter": Creates a resizable splitter container using QSplitter + Default is None. Attributes ---------- - scrollable : bool - Whether the container is scrollable. - widget : QWidget or QScrollArea + container_type : str + The type of container. + widget : QWidget, QScrollArea, or QSplitter The main widget that contains all child components. - layout : QVBoxLayout - The vertical layout that arranges child components. + layout : QVBoxLayout or None + The vertical layout that arranges child components (None for splitter). Examples -------- @@ -36,14 +39,20 @@ class Container(Component): >>> container.add(some_component) >>> # Create a scrollable container - >>> scrollable_container = Container(scrollable=True) + >>> scrollable_container = Container(container_type="scrollable") >>> scrollable_container.add(component1) >>> scrollable_container.add(component2) + + >>> # Create a splitter container + >>> splitter_container = Container(container_type="splitter") + >>> splitter_container.add(component1) + >>> splitter_container.add(component2) """ - def __init__(self, scrollable=False): - self.scrollable = scrollable - if self.scrollable: + def __init__(self, container_type=None): + self.container_type = container_type + self.children = [] + if self.container_type == "scrollable": self.widget = QScrollArea() self.widget.setWidgetResizable(True) self._scroll_content = QWidget() @@ -54,6 +63,10 @@ def __init__(self, scrollable=False): self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) self._scroll_layout.addLayout(self.layout) + elif self.container_type == "splitter": + self.widget = QSplitter(Qt.Orientation.Vertical) + self.widget.setChildrenCollapsible(True) + self.layout = None # Splitter doesn't use layout else: self.widget = QWidget() self.layout = QVBoxLayout() @@ -63,21 +76,46 @@ def __init__(self, scrollable=False): def add(self, component: "Component") -> None: """Add a component to the container.""" - self.layout.addWidget(component.widget) + if self.container_type == "splitter": + self.widget.addWidget(component.widget) + child_count = self.widget.count() + height = self.widget.height() + if child_count > 0: + equal_sizes = [height // child_count] * child_count + self.widget.setSizes(equal_sizes) + else: + self.layout.addWidget(component.widget) self.children.append(component) def remove(self, component: "Component") -> None: """Remove a component from the container.""" - self.layout.removeWidget(component.widget) + if self.container_type == "splitter": + component.widget.setParent(None) + else: + self.layout.removeWidget(component.widget) self.children.remove(component) + def update(self): + """Update the container and its children.""" + self.widget.update() + for child in self.children: + child.update() + def reset(self): """Reset the container to its initial state.""" self.children = [] - while self.layout.count(): - child = self.layout.takeAt(0) - if child.widget(): - child.widget().setParent(None) + if self.container_type == "splitter": + # For splitter, remove all widgets + while self.widget.count(): + child = self.widget.widget(0) + if child: + child.setParent(None) + else: + # For layout-based containers + while self.layout.count(): + child = self.layout.takeAt(0) + if child.widget(): + child.widget().setParent(None) def display_text(self, text: str) -> None: """Display a text when there is nothing else to show.""" @@ -85,4 +123,7 @@ def display_text(self, text: str) -> None: label = QLabel(text) label.setAlignment(Qt.AlignCenter) label.setStyleSheet("color: gray; font-style: italic; padding: 10px;") - self.layout.addWidget(label) + if self.container_type == "splitter": + self.widget.addWidget(label) + else: + self.layout.addWidget(label) diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 6f194e806d..72a5e2920d 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -1,16 +1,8 @@ -from PySide6.QtCore import Qt -from PySide6.QtWidgets import QHBoxLayout -from PySide6.QtWidgets import QLabel -from PySide6.QtWidgets import QScrollArea -from PySide6.QtWidgets import QVBoxLayout -from PySide6.QtWidgets import QWidget - from .container import Container -from compas_viewer.components.booleantoggle import BooleanToggle -from compas_viewer.components.colorpicker import ColorPicker -from compas_viewer.components.component import Component -from compas_viewer.components.numberedit import NumberEdit -from compas_viewer.components.textedit import TextEdit +from .booleantoggle import BooleanToggle +from .colorpicker import ColorPicker +from .numberedit import NumberEdit +from .textedit import TextEdit from compas_viewer.scene import ViewerSceneObject @@ -21,7 +13,7 @@ class ObjectSetting(Container): """ def __init__(self): - super().__init__(scrollable=True) + super().__init__(container_type="scrollable") @property def selected(self): diff --git a/src/compas_viewer/components/sceneform.py b/src/compas_viewer/components/sceneform.py index 69d79f16fa..4d19b8645e 100644 --- a/src/compas_viewer/components/sceneform.py +++ b/src/compas_viewer/components/sceneform.py @@ -62,10 +62,6 @@ def __init__( self.widget.itemClicked.connect(self.on_item_clicked) self.widget.itemSelectionChanged.connect(self.on_item_selection_changed) - @property - def scene(self): - return self.viewer.scene - def update(self, refresh: bool = False): if list(self.scene.objects) == self._sceneobjects and not refresh: for node in self.scene.traverse("breadthfirst"): diff --git a/src/compas_viewer/ui/container.py b/src/compas_viewer/ui/container.py deleted file mode 100644 index 13f6bf2b5c..0000000000 --- a/src/compas_viewer/ui/container.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import TYPE_CHECKING - -from PySide6.QtWidgets import QLayout -from PySide6.QtWidgets import QSizePolicy - -if TYPE_CHECKING: - from .ui import UI - - -class Container: - def __init__(self, ui: "UI", *args, **kwargs): - super().__init__(*args, **kwargs) - self.ui = ui - self.widget: QLayout = None - self.children = [] - - def add(self, ui_element): - self.children.append(ui_element) - self.widget.addWidget(ui_element.widget) - - def remove(self, ui_element): - self.children.remove(ui_element) - self.widget.removeWidget(ui_element.widget) - - @property - def show(self): - return self.widget.isVisible() - - @show.setter - def show(self, value: bool): - self.widget.setVisible(value) - - def update(self): - self.widget.update() - - # TODO: Avoid double updates - for child in self.children: - child.update() diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 63b1d3a528..a764a6529e 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -6,26 +6,28 @@ from compas_viewer.components import Sceneform from compas_viewer.components.objectsetting import ObjectSetting -from compas_viewer.ui.container import Container - -if TYPE_CHECKING: - from .ui import UI +from compas_viewer.components.container import Container class SideBarRight(Container): - def __init__(self, ui: "UI", show: bool, items: list[dict[str, Callable]]) -> None: - super().__init__(ui) - self.widget: QSplitter = QSplitter(QtCore.Qt.Orientation.Vertical) - self.widget.setChildrenCollapsible(True) - - self.show = show - self.items = items - - def load_items(self) -> None: - if not self.items: - return - - for item in self.items: + """Sidebar for the right side of the window, containing commonly used forms like sceneform and objectsetting. + + Parameters + ---------- + items : list[dict[str, Callable]] + List of items to be added to the sidebar. + + Attributes + ---------- + sceneform : Sceneform + Sceneform component, if it is in the items list. + object_setting : ObjectSetting + ObjectSetting component, if it is in the items list. + """ + + def __init__(self, items: list[dict[str, Callable]]) -> None: + super().__init__(container_type="splitter") + for item in items: itemtype = item.get("type", None) if itemtype == "Sceneform": @@ -38,10 +40,3 @@ def load_items(self) -> None: if itemtype == "ObjectSetting": self.object_setting = ObjectSetting() self.add(self.object_setting) - - # Set equal sizes for all children - child_count = self.widget.count() - if child_count > 0: - # Set each child to have equal size (1000 is arbitrary but proportional) - equal_sizes = [1000] * child_count - self.widget.setSizes(equal_sizes) diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index b3dc5e125b..e3b82e4774 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -31,8 +31,6 @@ def __init__(self, viewer: "Viewer") -> None: show=self.viewer.config.ui.toolbar.show, ) self.sidebar = SideBarRight( - self, - show=self.viewer.config.ui.sidebar.show, items=self.viewer.config.ui.sidebar.items, ) self.viewport = ViewPort( @@ -44,15 +42,15 @@ def __init__(self, viewer: "Viewer") -> None: self, show=self.viewer.config.ui.sidedock.show, ) - # TODO: find better solution to transient window - self.sidebar.load_items() + self.window.widget.setCentralWidget(self.viewport.widget) self.window.widget.addDockWidget(SideDock.locations["left"], self.sidedock.widget) def init(self): self.resize(self.viewer.config.window.width, self.viewer.config.window.height) self.window.widget.show() - self.sidebar.update() + + self.sidebar.show = self.viewer.config.ui.sidebar.show def resize(self, w: int, h: int) -> None: self.window.widget.resize(w, h) From 87eead8719a39f4a44a7dab6f8c5a7b6c5cb58cf Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 20:34:42 +0200 Subject: [PATCH 07/18] ui --- src/compas_viewer/ui/menubar.py | 23 +++++++++------------ src/compas_viewer/ui/sidebar.py | 5 ----- src/compas_viewer/ui/sidedock.py | 27 ++++-------------------- src/compas_viewer/ui/statusbar.py | 31 ++++++++-------------------- src/compas_viewer/ui/toolbar.py | 34 +++++++------------------------ src/compas_viewer/ui/ui.py | 32 +++++++++-------------------- 6 files changed, 39 insertions(+), 113 deletions(-) diff --git a/src/compas_viewer/ui/menubar.py b/src/compas_viewer/ui/menubar.py index 6d8b304950..3891464ef8 100644 --- a/src/compas_viewer/ui/menubar.py +++ b/src/compas_viewer/ui/menubar.py @@ -11,22 +11,19 @@ from PySide6.QtWidgets import QWidget from compas_viewer.commands import Command +from .mainwindow import MainWindow +from compas_viewer.components.component import Component -if TYPE_CHECKING: - from .ui import UI - -class MenuBar: - def __init__(self, ui: "UI", items: list[dict]) -> None: - self.ui = ui +class MenuBar(Component): + def __init__(self, window: MainWindow, items: list[dict]) -> None: + super().__init__() self.items = items - self.widget: QMenuBar = self.ui.window.widget.menuBar() - self.widget.clear() - self.add_menu(items=self.items, parent=self.widget) + self.widget: QMenuBar = window.widget.menuBar() - def add_menu(self, *, items, parent: QMenu) -> list[QAction]: - if not items: - return + def add_menu(self, items=None, parent=None) -> list[QAction]: + items = items or self.items + parent = parent or self.widget actions = [] @@ -47,7 +44,7 @@ def add_menu(self, *, items, parent: QMenu) -> list[QAction]: if itemtype == "checkbox": state = item.get("checked", False) a.setCheckable(True) - a.setChecked(state if not callable(state) else state(self.ui.viewer)) + a.setChecked(state if not callable(state) else state(self.viewer)) if isinstance(action, Command): if action.keybinding is not None: diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index a764a6529e..650281990b 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -1,9 +1,4 @@ -from typing import TYPE_CHECKING from typing import Callable - -from PySide6 import QtCore -from PySide6.QtWidgets import QSplitter - from compas_viewer.components import Sceneform from compas_viewer.components.objectsetting import ObjectSetting from compas_viewer.components.container import Container diff --git a/src/compas_viewer/ui/sidedock.py b/src/compas_viewer/ui/sidedock.py index e62ded813f..cbdc41f521 100644 --- a/src/compas_viewer/ui/sidedock.py +++ b/src/compas_viewer/ui/sidedock.py @@ -1,21 +1,17 @@ -from typing import TYPE_CHECKING - from PySide6 import QtCore from PySide6 import QtWidgets from PySide6.QtWidgets import QDockWidget - -if TYPE_CHECKING: - from .ui import UI +from compas_viewer.components.container import Container -class SideDock: +class SideDock(Container): locations = { "left": QtCore.Qt.DockWidgetArea.LeftDockWidgetArea, "right": QtCore.Qt.DockWidgetArea.RightDockWidgetArea, } - def __init__(self, ui: "UI", show: bool = False) -> None: - self.ui = ui + def __init__(self) -> None: + super().__init__(container_type="scrollable") self.widget = QDockWidget() self.widget.setMinimumWidth(200) self.scroll = QtWidgets.QScrollArea() @@ -28,18 +24,3 @@ def __init__(self, ui: "UI", show: bool = False) -> None: self.layout.setAlignment(QtCore.Qt.AlignTop) self.widget.setAllowedAreas(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea | QtCore.Qt.DockWidgetArea.RightDockWidgetArea) self.widget.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable) - self.show = show - - @property - def show(self): - return self.widget.isVisible() - - @show.setter - def show(self, value: bool): - if value: - self.widget.setVisible(True) - elif not value: - self.widget.setHidden(True) - - def add(self, widget): - self.layout.addWidget(widget) diff --git a/src/compas_viewer/ui/statusbar.py b/src/compas_viewer/ui/statusbar.py index 7d7ae6a126..094f8d5d22 100644 --- a/src/compas_viewer/ui/statusbar.py +++ b/src/compas_viewer/ui/statusbar.py @@ -1,25 +1,10 @@ -from typing import TYPE_CHECKING +from compas_viewer.components.component import Component +from PySide6.QtWidgets import QLabel +from .mainwindow import MainWindow -from compas_viewer.components.label import LabelWidget -if TYPE_CHECKING: - from .ui import UI - - -class SatusBar: - def __init__(self, ui: "UI", show: bool = True) -> None: - self.ui = ui - self.widget = self.ui.window.widget.statusBar() - self.widget.addWidget(LabelWidget(text="Ready...")) - self.show = show - - @property - def show(self): - return self.widget.isVisible() - - @show.setter - def show(self, value: bool): - if value: - self.widget.setVisible(True) - elif not value: - self.widget.setHidden(True) +class StatusBar(Component): + def __init__(self, window: MainWindow) -> None: + super().__init__() + self.widget = window.widget.statusBar() + self.widget.addWidget(QLabel(text="Ready...")) diff --git a/src/compas_viewer/ui/toolbar.py b/src/compas_viewer/ui/toolbar.py index 7c4e9e57e8..172e75bd1e 100644 --- a/src/compas_viewer/ui/toolbar.py +++ b/src/compas_viewer/ui/toolbar.py @@ -1,30 +1,21 @@ from functools import partial -from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Optional - +from .mainwindow import MainWindow from compas_viewer.components import Button +from compas_viewer.components.component import Component -if TYPE_CHECKING: - from .ui import UI - - -class ToolBar: - def __init__(self, ui: "UI", items: list[dict], show: bool = True) -> None: - self.ui = ui - self.items = items - self.widget = self.ui.window.widget.addToolBar("Tools") +class ToolBar(Component): + def __init__(self, window: MainWindow, items: list[dict]) -> None: + super().__init__() + self.widget = window.widget.addToolBar("Tools") self.widget.clear() self.widget.setMovable(False) self.widget.setObjectName("Tools") - self.show = show - if not self.items: - return - - for item in self.items: + for item in items: text = item.get("title", None) tooltip = item.get("tooltip", None) itemtype = item.get("type", None) @@ -64,14 +55,3 @@ def add_action( # combobox.addItem(item["title"], item.get("value", item["title"])) # combobox.currentIndexChanged.connect(lambda index: action(combobox.itemData(index))) # self.widget.addWidget(combobox) - - @property - def show(self): - return self.widget.isVisible() - - @show.setter - def show(self, value: bool): - if value: - self.widget.setVisible(True) - elif not value: - self.widget.setHidden(True) diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index e3b82e4774..163b0babd6 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -4,7 +4,7 @@ from .menubar import MenuBar from .sidebar import SideBarRight from .sidedock import SideDock -from .statusbar import SatusBar +from .statusbar import StatusBar from .toolbar import ToolBar from .viewport import ViewPort @@ -16,32 +16,16 @@ class UI: def __init__(self, viewer: "Viewer") -> None: self.viewer = viewer self.window = MainWindow(title=self.viewer.config.window.title) - - self.menubar = MenuBar( - self, - items=self.viewer.config.ui.menubar.items, - ) - self.statusbar = SatusBar( - self, - show=self.viewer.config.ui.statusbar.show, - ) - self.toolbar = ToolBar( - self, - items=self.viewer.config.ui.toolbar.items, - show=self.viewer.config.ui.toolbar.show, - ) - self.sidebar = SideBarRight( - items=self.viewer.config.ui.sidebar.items, - ) + self.menubar = MenuBar(self.window, items=self.viewer.config.ui.menubar.items) + self.toolbar = ToolBar(self.window, items=self.viewer.config.ui.toolbar.items) + self.statusbar = StatusBar(self.window) + self.sidebar = SideBarRight(items=self.viewer.config.ui.sidebar.items) + self.sidedock = SideDock() self.viewport = ViewPort( self, self.viewer.renderer, self.sidebar, ) - self.sidedock = SideDock( - self, - show=self.viewer.config.ui.sidedock.show, - ) self.window.widget.setCentralWidget(self.viewport.widget) self.window.widget.addDockWidget(SideDock.locations["left"], self.sidedock.widget) @@ -50,7 +34,11 @@ def init(self): self.resize(self.viewer.config.window.width, self.viewer.config.window.height) self.window.widget.show() + self.menubar.add_menu() + self.menubar.show = self.viewer.config.ui.menubar.show + self.toolbar.show = self.viewer.config.ui.toolbar.show self.sidebar.show = self.viewer.config.ui.sidebar.show + self.sidedock.show = self.viewer.config.ui.sidedock.show def resize(self, w: int, h: int) -> None: self.window.widget.resize(w, h) From 4bfef64b508195cff5f5dd3d6f4e996a16c7ef65 Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 22:29:41 +0200 Subject: [PATCH 08/18] lint etc --- src/compas_viewer/components/booleantoggle.py | 12 ++++++----- .../components/boundcomponent.py | 3 ++- src/compas_viewer/components/colorpicker.py | 7 ++++--- src/compas_viewer/components/component.py | 1 + src/compas_viewer/components/container.py | 9 ++++---- src/compas_viewer/components/layout.py | 2 +- src/compas_viewer/components/numberedit.py | 12 ++++++----- src/compas_viewer/components/objectsetting.py | 6 +++--- src/compas_viewer/components/textedit.py | 10 +++++---- src/compas_viewer/ui/mainwindow.py | 17 ++++++++++++--- src/compas_viewer/ui/menubar.py | 5 ++--- src/compas_viewer/ui/sidebar.py | 3 ++- src/compas_viewer/ui/sidedock.py | 6 +++++- src/compas_viewer/ui/statusbar.py | 4 +++- src/compas_viewer/ui/toolbar.py | 4 +++- src/compas_viewer/ui/ui.py | 13 ++++-------- src/compas_viewer/ui/viewport.py | 21 +++++++++---------- 17 files changed, 79 insertions(+), 56 deletions(-) diff --git a/src/compas_viewer/components/booleantoggle.py b/src/compas_viewer/components/booleantoggle.py index 8ee59d4d06..f53dca1a94 100644 --- a/src/compas_viewer/components/booleantoggle.py +++ b/src/compas_viewer/components/booleantoggle.py @@ -1,11 +1,13 @@ -from PySide6.QtWidgets import QWidget -from PySide6.QtWidgets import QHBoxLayout -from PySide6.QtWidgets import QLabel -from PySide6.QtWidgets import QCheckBox from typing import Callable from typing import Union -from .component import Component + +from PySide6.QtWidgets import QCheckBox +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QWidget + from .boundcomponent import BoundComponent +from .component import Component class BooleanToggle(BoundComponent): diff --git a/src/compas_viewer/components/boundcomponent.py b/src/compas_viewer/components/boundcomponent.py index f45bc4c895..a3012e03a3 100644 --- a/src/compas_viewer/components/boundcomponent.py +++ b/src/compas_viewer/components/boundcomponent.py @@ -1,6 +1,7 @@ +from typing import Any from typing import Callable from typing import Union -from typing import Any + from .component import Component diff --git a/src/compas_viewer/components/colorpicker.py b/src/compas_viewer/components/colorpicker.py index 645ea07dfc..98d36a0afa 100644 --- a/src/compas_viewer/components/colorpicker.py +++ b/src/compas_viewer/components/colorpicker.py @@ -3,15 +3,16 @@ from PySide6.QtGui import QColor from PySide6.QtWidgets import QColorDialog -from PySide6.QtWidgets import QPushButton -from PySide6.QtWidgets import QWidget from PySide6.QtWidgets import QHBoxLayout from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QPushButton +from PySide6.QtWidgets import QWidget from compas.colors import Color from compas.colors.colordict import ColorDict -from .component import Component + from .boundcomponent import BoundComponent +from .component import Component def remap_rgb(value, to_range_one=True): diff --git a/src/compas_viewer/components/component.py b/src/compas_viewer/components/component.py index 35c580e5b6..9f047bcbb3 100644 --- a/src/compas_viewer/components/component.py +++ b/src/compas_viewer/components/component.py @@ -1,4 +1,5 @@ from PySide6.QtWidgets import QWidget + from compas_viewer.base import Base diff --git a/src/compas_viewer/components/container.py b/src/compas_viewer/components/container.py index 3235bb8ef1..cc51e8449f 100644 --- a/src/compas_viewer/components/container.py +++ b/src/compas_viewer/components/container.py @@ -1,9 +1,10 @@ -from PySide6.QtWidgets import QWidget -from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QLabel from PySide6.QtWidgets import QScrollArea from PySide6.QtWidgets import QSplitter -from PySide6.QtWidgets import QLabel -from PySide6.QtCore import Qt +from PySide6.QtWidgets import QVBoxLayout +from PySide6.QtWidgets import QWidget + from .component import Component diff --git a/src/compas_viewer/components/layout.py b/src/compas_viewer/components/layout.py index acd5271f4f..26d377f4b3 100644 --- a/src/compas_viewer/components/layout.py +++ b/src/compas_viewer/components/layout.py @@ -9,8 +9,8 @@ from PySide6.QtWidgets import QVBoxLayout from compas_viewer.components.colorpicker import ColorPicker -from compas_viewer.components.numberedit import NumberEdit from compas_viewer.components.label import LabelWidget +from compas_viewer.components.numberedit import NumberEdit from compas_viewer.components.textedit import TextEdit if TYPE_CHECKING: diff --git a/src/compas_viewer/components/numberedit.py b/src/compas_viewer/components/numberedit.py index 3005fc4e61..1e3e29c01e 100644 --- a/src/compas_viewer/components/numberedit.py +++ b/src/compas_viewer/components/numberedit.py @@ -1,11 +1,13 @@ -from PySide6.QtWidgets import QWidget -from PySide6.QtWidgets import QHBoxLayout -from PySide6.QtWidgets import QLabel -from PySide6.QtWidgets import QDoubleSpinBox from typing import Callable from typing import Union -from .component import Component + +from PySide6.QtWidgets import QDoubleSpinBox +from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QWidget + from .boundcomponent import BoundComponent +from .component import Component class NumberEdit(BoundComponent): diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 72a5e2920d..9b0db5d1b5 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -1,11 +1,11 @@ -from .container import Container +from compas_viewer.scene import ViewerSceneObject + from .booleantoggle import BooleanToggle from .colorpicker import ColorPicker +from .container import Container from .numberedit import NumberEdit from .textedit import TextEdit -from compas_viewer.scene import ViewerSceneObject - class ObjectSetting(Container): """ diff --git a/src/compas_viewer/components/textedit.py b/src/compas_viewer/components/textedit.py index 1b1ec89523..cdfc8e7aa9 100644 --- a/src/compas_viewer/components/textedit.py +++ b/src/compas_viewer/components/textedit.py @@ -1,13 +1,15 @@ +from typing import Callable +from typing import Union + from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout +from PySide6.QtWidgets import QLabel from PySide6.QtWidgets import QSizePolicy from PySide6.QtWidgets import QTextEdit from PySide6.QtWidgets import QWidget -from PySide6.QtWidgets import QLabel -from typing import Callable -from typing import Union -from .component import Component + from .boundcomponent import BoundComponent +from .component import Component class TextEdit(BoundComponent): diff --git a/src/compas_viewer/ui/mainwindow.py b/src/compas_viewer/ui/mainwindow.py index e150ec530a..1a4c896df9 100644 --- a/src/compas_viewer/ui/mainwindow.py +++ b/src/compas_viewer/ui/mainwindow.py @@ -1,8 +1,19 @@ from PySide6.QtWidgets import QMainWindow +from compas_viewer.components.component import Component -class MainWindow: - def __init__(self, title): - self.title = title + +class MainWindow(Component): + def __init__(self, title: str = "COMPAS Viewer"): + super().__init__() self.widget = QMainWindow() + self.title = title + + @property + def title(self): + return self.widget.windowTitle() + + @title.setter + def title(self, title: str): + self._title = title self.widget.setWindowTitle(title) diff --git a/src/compas_viewer/ui/menubar.py b/src/compas_viewer/ui/menubar.py index 3891464ef8..a414a2283d 100644 --- a/src/compas_viewer/ui/menubar.py +++ b/src/compas_viewer/ui/menubar.py @@ -1,19 +1,18 @@ from functools import partial -from typing import TYPE_CHECKING from typing import Any from typing import Callable from typing import Optional from PySide6.QtGui import QAction from PySide6.QtGui import QActionGroup -from PySide6.QtWidgets import QMenu from PySide6.QtWidgets import QMenuBar from PySide6.QtWidgets import QWidget from compas_viewer.commands import Command -from .mainwindow import MainWindow from compas_viewer.components.component import Component +from .mainwindow import MainWindow + class MenuBar(Component): def __init__(self, window: MainWindow, items: list[dict]) -> None: diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 650281990b..17962188ff 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -1,7 +1,8 @@ from typing import Callable + from compas_viewer.components import Sceneform -from compas_viewer.components.objectsetting import ObjectSetting from compas_viewer.components.container import Container +from compas_viewer.components.objectsetting import ObjectSetting class SideBarRight(Container): diff --git a/src/compas_viewer/ui/sidedock.py b/src/compas_viewer/ui/sidedock.py index cbdc41f521..deec6d9834 100644 --- a/src/compas_viewer/ui/sidedock.py +++ b/src/compas_viewer/ui/sidedock.py @@ -1,8 +1,11 @@ from PySide6 import QtCore from PySide6 import QtWidgets from PySide6.QtWidgets import QDockWidget + from compas_viewer.components.container import Container +from .mainwindow import MainWindow + class SideDock(Container): locations = { @@ -10,7 +13,7 @@ class SideDock(Container): "right": QtCore.Qt.DockWidgetArea.RightDockWidgetArea, } - def __init__(self) -> None: + def __init__(self, window: MainWindow) -> None: super().__init__(container_type="scrollable") self.widget = QDockWidget() self.widget.setMinimumWidth(200) @@ -24,3 +27,4 @@ def __init__(self) -> None: self.layout.setAlignment(QtCore.Qt.AlignTop) self.widget.setAllowedAreas(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea | QtCore.Qt.DockWidgetArea.RightDockWidgetArea) self.widget.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetFloatable | QDockWidget.DockWidgetFeature.DockWidgetMovable) + window.widget.addDockWidget(self.locations["left"], self.widget) diff --git a/src/compas_viewer/ui/statusbar.py b/src/compas_viewer/ui/statusbar.py index 094f8d5d22..5a3399bdab 100644 --- a/src/compas_viewer/ui/statusbar.py +++ b/src/compas_viewer/ui/statusbar.py @@ -1,5 +1,7 @@ -from compas_viewer.components.component import Component from PySide6.QtWidgets import QLabel + +from compas_viewer.components.component import Component + from .mainwindow import MainWindow diff --git a/src/compas_viewer/ui/toolbar.py b/src/compas_viewer/ui/toolbar.py index 172e75bd1e..e25e22d787 100644 --- a/src/compas_viewer/ui/toolbar.py +++ b/src/compas_viewer/ui/toolbar.py @@ -2,10 +2,12 @@ from typing import Any from typing import Callable from typing import Optional -from .mainwindow import MainWindow + from compas_viewer.components import Button from compas_viewer.components.component import Component +from .mainwindow import MainWindow + class ToolBar(Component): def __init__(self, window: MainWindow, items: list[dict]) -> None: diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 163b0babd6..92a4c8b6c0 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -20,15 +20,8 @@ def __init__(self, viewer: "Viewer") -> None: self.toolbar = ToolBar(self.window, items=self.viewer.config.ui.toolbar.items) self.statusbar = StatusBar(self.window) self.sidebar = SideBarRight(items=self.viewer.config.ui.sidebar.items) - self.sidedock = SideDock() - self.viewport = ViewPort( - self, - self.viewer.renderer, - self.sidebar, - ) - - self.window.widget.setCentralWidget(self.viewport.widget) - self.window.widget.addDockWidget(SideDock.locations["left"], self.sidedock.widget) + self.sidedock = SideDock(self.window) + self.viewport = ViewPort(self.window, self.viewer.renderer, self.sidebar) def init(self): self.resize(self.viewer.config.window.width, self.viewer.config.window.height) @@ -40,6 +33,8 @@ def init(self): self.sidebar.show = self.viewer.config.ui.sidebar.show self.sidedock.show = self.viewer.config.ui.sidedock.show + self.sidebar.update() + def resize(self, w: int, h: int) -> None: self.window.widget.resize(w, h) rect = self.viewer.app.primaryScreen().availableGeometry() diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index 267ae62a98..07d44b9eb2 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -1,18 +1,17 @@ -from typing import TYPE_CHECKING +from PySide6.QtWidgets import QSplitter -from PySide6 import QtWidgets +from compas_viewer.components.component import Component +from compas_viewer.renderer import Renderer -if TYPE_CHECKING: - from compas_viewer.renderer import Renderer +from .mainwindow import MainWindow +from .sidebar import SideBarRight - from .sidebar import SideBarRight - from .ui import UI - -class ViewPort: - def __init__(self, ui: "UI", renderer: "Renderer", sidebar: "SideBarRight"): - self.ui = ui - self.widget = QtWidgets.QSplitter() +class ViewPort(Component): + def __init__(self, window: MainWindow, renderer: Renderer, sidebar: SideBarRight): + super().__init__() + self.widget = QSplitter() self.widget.addWidget(renderer) self.widget.addWidget(sidebar.widget) self.widget.setSizes([800, 200]) + window.widget.setCentralWidget(self.widget) From 45a47aebf230c3efb8d4453bb800297d175a4b99 Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 22:59:59 +0200 Subject: [PATCH 09/18] fix singleton --- src/compas_viewer/components/camerasetting.py | 7 +- src/compas_viewer/components/layout.py | 169 ------------------ src/compas_viewer/events.py | 10 +- src/compas_viewer/renderer/renderer.py | 15 +- src/compas_viewer/singleton.py | 6 +- src/compas_viewer/ui/ui.py | 12 +- src/compas_viewer/ui/viewport.py | 5 +- src/compas_viewer/viewer.py | 23 ++- 8 files changed, 40 insertions(+), 207 deletions(-) delete mode 100644 src/compas_viewer/components/layout.py diff --git a/src/compas_viewer/components/camerasetting.py b/src/compas_viewer/components/camerasetting.py index 09cfb23102..eb0f410b2a 100644 --- a/src/compas_viewer/components/camerasetting.py +++ b/src/compas_viewer/components/camerasetting.py @@ -3,7 +3,8 @@ from PySide6.QtWidgets import QVBoxLayout from compas_viewer.base import Base -from compas_viewer.components.layout import SettingLayout + +# from compas_viewer.components.layout import SettingLayout class CameraSettingsDialog(QDialog, Base): @@ -44,8 +45,8 @@ def __init__(self, items: list[dict]) -> None: self.setWindowTitle("Camera Settings") self.layout = QVBoxLayout(self) - self.setting_layout = SettingLayout(viewer=self.viewer, items=items, type="camera_setting") - self.setting_layout.generate_layout() + # self.setting_layout = SettingLayout(viewer=self.viewer, items=items, type="camera_setting") + # self.setting_layout.generate_layout() self.layout.addLayout(self.setting_layout.layout) diff --git a/src/compas_viewer/components/layout.py b/src/compas_viewer/components/layout.py deleted file mode 100644 index 26d377f4b3..0000000000 --- a/src/compas_viewer/components/layout.py +++ /dev/null @@ -1,169 +0,0 @@ -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.colorpicker import ColorPicker -from compas_viewer.components.label import LabelWidget -from compas_viewer.components.numberedit import NumberEdit -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 - - -class SettingLayout: - """ - 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. - - Methods - ------- - 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": "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}]}, - >>> ] - """ - - def __init__( - self, - viewer: "Viewer", - items: list[dict], - type: Literal["obj_setting", "camera_setting"], - ): - super().__init__() - - self.viewer = viewer - self.items = items - self.type = type - - @property - def exclude_type_list(self) -> tuple[type, ...]: - from compas_viewer.scene import Group - from compas_viewer.scene import TagObject - - return (Group, TagObject) - - def generate_layout(self) -> None: - self.layout = QVBoxLayout() - self.widgets = {} - - if self.type == "camera_setting": - self.set_layout(self.items, self.viewer.renderer.camera) - - elif self.type == "obj_setting": - obj_list = [] - for obj in self.viewer.scene.objects: - if obj.is_selected: - obj_list.append(obj) - - if not obj_list or isinstance(obj_list[0], self.exclude_type_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 attr and getattr(obj, attr, None) is None: - # TODO: @Tsai, this needs to be handled at upper level. - continue - - if type == "double_edit": - value = action(obj) - widget = NumberEdit(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 = ColorPicker(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) - - self.layout.addLayout(sub_layout) diff --git a/src/compas_viewer/events.py b/src/compas_viewer/events.py index d9d842f2cc..9a3665d16a 100644 --- a/src/compas_viewer/events.py +++ b/src/compas_viewer/events.py @@ -1,4 +1,3 @@ -from typing import TYPE_CHECKING from typing import Literal from PySide6.QtCore import QObject @@ -9,9 +8,7 @@ from PySide6.QtGui import QWheelEvent from PySide6.QtWidgets import QGestureEvent -if TYPE_CHECKING: - from compas_viewer import Viewer - +from compas_viewer.base import Base mousebutton_constant = { "LEFT": Qt.MouseButton.LeftButton, @@ -237,7 +234,7 @@ def __str__(self): return f"{self.title}" -class EventManager: +class EventManager(Base): """Class representing a manager for user inout events. Parameters @@ -256,8 +253,7 @@ class EventManager: """ - def __init__(self, viewer: "Viewer") -> None: - self.viewer = viewer + def __init__(self) -> None: self.key_events: list[KeyEvent] = [] self.mouse_events: list[MouseEvent] = [] self.wheel_events: list[WheelEvent] = [] diff --git a/src/compas_viewer/renderer/renderer.py b/src/compas_viewer/renderer/renderer.py index 92bc058a72..096e881191 100644 --- a/src/compas_viewer/renderer/renderer.py +++ b/src/compas_viewer/renderer/renderer.py @@ -11,6 +11,7 @@ from PySide6.QtGui import QDropEvent from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QMouseEvent +from PySide6.QtGui import QSurfaceFormat from PySide6.QtGui import QWheelEvent from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import QGestureEvent @@ -18,6 +19,7 @@ from compas.geometry import Frame from compas.geometry import transform_points_numpy from compas.scene import Group +from compas_viewer.base import Base from compas_viewer.gl import OffscreenBufferContext from compas_viewer.scene import TagObject from compas_viewer.scene.buffermanager import BufferManager @@ -27,12 +29,11 @@ from .shaders import Shader if TYPE_CHECKING: - from compas_viewer import Viewer from compas_viewer.scene.gridobject import GridObject from compas_viewer.scene.meshobject import MeshObject -class Renderer(QOpenGLWidget): +class Renderer(QOpenGLWidget, Base): """ Renderer class for 3D rendering of COMPAS geometry. We constantly use OpenGL version 2.1 and GLSL 120 with a Compatibility Profile at the moment. @@ -52,10 +53,14 @@ class Renderer(QOpenGLWidget): # Enhance pixel width for selection. PIXEL_SELECTION_INCREMENTAL = 2 - def __init__(self, viewer: "Viewer"): - super().__init__() + def __init__(self): + format = QSurfaceFormat() + format.setVersion(3, 3) + format.setProfile(QSurfaceFormat.CoreProfile) + format.setSamples(4) # Enable 4x MSAA (optional, can be set to 8, etc.) + QSurfaceFormat.setDefaultFormat(format) - self.viewer = viewer + super().__init__() self._view = self.viewer.config.renderer.view self._rendermode = self.viewer.config.renderer.rendermode diff --git a/src/compas_viewer/singleton.py b/src/compas_viewer/singleton.py index b695dc6908..ec53fd5359 100644 --- a/src/compas_viewer/singleton.py +++ b/src/compas_viewer/singleton.py @@ -12,8 +12,12 @@ def __call__(cls, *args, **kwargs): key_class = key_class.__base__ if key_class not in cls._instances: - instance = super().__call__(*args, **kwargs) + # Create the instance without calling __init__ + instance = cls.__new__(cls) + # Store it immediately so it's available during __init__ cls._instances[key_class] = instance + # Now call __init__ on the stored instance + instance.__init__(*args, **kwargs) return cls._instances[key_class] diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 92a4c8b6c0..63439490c5 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING +from compas_viewer.base import Base from .mainwindow import MainWindow from .menubar import MenuBar @@ -8,20 +8,16 @@ from .toolbar import ToolBar from .viewport import ViewPort -if TYPE_CHECKING: - from compas_viewer import Viewer - -class UI: - def __init__(self, viewer: "Viewer") -> None: - self.viewer = viewer +class UI(Base): + def __init__(self) -> None: self.window = MainWindow(title=self.viewer.config.window.title) self.menubar = MenuBar(self.window, items=self.viewer.config.ui.menubar.items) self.toolbar = ToolBar(self.window, items=self.viewer.config.ui.toolbar.items) self.statusbar = StatusBar(self.window) self.sidebar = SideBarRight(items=self.viewer.config.ui.sidebar.items) self.sidedock = SideDock(self.window) - self.viewport = ViewPort(self.window, self.viewer.renderer, self.sidebar) + self.viewport = ViewPort(self.window, self.sidebar) def init(self): self.resize(self.viewer.config.window.width, self.viewer.config.window.height) diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index 07d44b9eb2..9e130478c3 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -8,10 +8,11 @@ class ViewPort(Component): - def __init__(self, window: MainWindow, renderer: Renderer, sidebar: SideBarRight): + def __init__(self, window: MainWindow, sidebar: SideBarRight): super().__init__() self.widget = QSplitter() - self.widget.addWidget(renderer) + self.renderer = Renderer() + self.widget.addWidget(self.renderer) self.widget.addWidget(sidebar.widget) self.widget.setSizes([800, 200]) window.widget.setCentralWidget(self.widget) diff --git a/src/compas_viewer/viewer.py b/src/compas_viewer/viewer.py index 5dd5264130..0ca80c8688 100644 --- a/src/compas_viewer/viewer.py +++ b/src/compas_viewer/viewer.py @@ -5,7 +5,6 @@ from PySide6.QtCore import QTimer from PySide6.QtGui import QIcon -from PySide6.QtGui import QSurfaceFormat from PySide6.QtWidgets import QApplication from compas.scene import Scene @@ -21,12 +20,6 @@ class Viewer(Singleton): def __init__(self, config: Optional[Config] = None, **kwargs): - format = QSurfaceFormat() - format.setVersion(3, 3) - format.setProfile(QSurfaceFormat.CoreProfile) - format.setSamples(4) # Enable 4x MSAA (optional, can be set to 8, etc.) - QSurfaceFormat.setDefaultFormat(format) - self.running = False self.app = QApplication(sys.argv) self.app.setApplicationName("COMPAS Viewer") @@ -40,13 +33,19 @@ def __init__(self, config: Optional[Config] = None, **kwargs): self.timer = QTimer() self.mouse = Mouse() - self.eventmanager = EventManager(self) + self.eventmanager = EventManager() + self.ui = UI() - # renderer should be part of UI - self.renderer = Renderer(self) - self.ui = UI(self) + # TODO: move to init self.unit = self.config.unit + def init(self): + self.ui.init() + + @property + def renderer(self) -> Renderer: + return self.ui.viewport.renderer + @property def scene(self) -> ViewerScene: if self._scene is None: @@ -87,7 +86,7 @@ def unit(self, unit: str): def show(self): self.running = True - self.ui.init() + self.init() self.app.exec() def on(self, interval: int, frames: Optional[int] = None) -> Callable: From e6c9f6fcbf1d0637d79c73fe0fc8756b7f3478af Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 23:22:32 +0200 Subject: [PATCH 10/18] further clean up args --- src/compas_viewer/ui/mainwindow.py | 4 ++-- src/compas_viewer/ui/menubar.py | 10 ++++++---- src/compas_viewer/ui/sidebar.py | 11 +++++++++-- src/compas_viewer/ui/toolbar.py | 10 ++++++++-- src/compas_viewer/ui/ui.py | 13 ++++++++----- src/compas_viewer/ui/viewport.py | 5 +++-- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/compas_viewer/ui/mainwindow.py b/src/compas_viewer/ui/mainwindow.py index 1a4c896df9..e301dda3d0 100644 --- a/src/compas_viewer/ui/mainwindow.py +++ b/src/compas_viewer/ui/mainwindow.py @@ -4,10 +4,10 @@ class MainWindow(Component): - def __init__(self, title: str = "COMPAS Viewer"): + def __init__(self): super().__init__() self.widget = QMainWindow() - self.title = title + self.title = self.viewer.config.window.title @property def title(self): diff --git a/src/compas_viewer/ui/menubar.py b/src/compas_viewer/ui/menubar.py index a414a2283d..e20330065e 100644 --- a/src/compas_viewer/ui/menubar.py +++ b/src/compas_viewer/ui/menubar.py @@ -5,7 +5,6 @@ from PySide6.QtGui import QAction from PySide6.QtGui import QActionGroup -from PySide6.QtWidgets import QMenuBar from PySide6.QtWidgets import QWidget from compas_viewer.commands import Command @@ -15,10 +14,13 @@ class MenuBar(Component): - def __init__(self, window: MainWindow, items: list[dict]) -> None: + def __init__(self, window: MainWindow) -> None: super().__init__() - self.items = items - self.widget: QMenuBar = window.widget.menuBar() + self.widget = window.widget.menuBar() + + @property + def items(self): + return self.viewer.config.ui.menubar.items def add_menu(self, items=None, parent=None) -> list[QAction]: items = items or self.items diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 17962188ff..1cbb529e22 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -21,9 +21,16 @@ class SideBarRight(Container): ObjectSetting component, if it is in the items list. """ - def __init__(self, items: list[dict[str, Callable]]) -> None: + def __init__(self) -> None: super().__init__(container_type="splitter") - for item in items: + self.load_items() + + @property + def items(self): + return self.viewer.config.ui.sidebar.items + + def load_items(self): + for item in self.items: itemtype = item.get("type", None) if itemtype == "Sceneform": diff --git a/src/compas_viewer/ui/toolbar.py b/src/compas_viewer/ui/toolbar.py index e25e22d787..75c42567eb 100644 --- a/src/compas_viewer/ui/toolbar.py +++ b/src/compas_viewer/ui/toolbar.py @@ -10,14 +10,20 @@ class ToolBar(Component): - def __init__(self, window: MainWindow, items: list[dict]) -> None: + def __init__(self, window: MainWindow) -> None: super().__init__() self.widget = window.widget.addToolBar("Tools") self.widget.clear() self.widget.setMovable(False) self.widget.setObjectName("Tools") + self.load_items() - for item in items: + @property + def items(self): + return self.viewer.config.ui.toolbar.items + + def load_items(self): + for item in self.items: text = item.get("title", None) tooltip = item.get("tooltip", None) itemtype = item.get("type", None) diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 63439490c5..4d343a4527 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -11,13 +11,16 @@ class UI(Base): def __init__(self) -> None: - self.window = MainWindow(title=self.viewer.config.window.title) - self.menubar = MenuBar(self.window, items=self.viewer.config.ui.menubar.items) - self.toolbar = ToolBar(self.window, items=self.viewer.config.ui.toolbar.items) + self.window = MainWindow() + self.menubar = MenuBar(self.window) + self.toolbar = ToolBar(self.window) self.statusbar = StatusBar(self.window) - self.sidebar = SideBarRight(items=self.viewer.config.ui.sidebar.items) self.sidedock = SideDock(self.window) - self.viewport = ViewPort(self.window, self.sidebar) + self.viewport = ViewPort(self.window) + + @property + def sidebar(self): + return self.viewport.sidebar def init(self): self.resize(self.viewer.config.window.width, self.viewer.config.window.height) diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index 9e130478c3..d986d07fe8 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -8,11 +8,12 @@ class ViewPort(Component): - def __init__(self, window: MainWindow, sidebar: SideBarRight): + def __init__(self, window: MainWindow): super().__init__() self.widget = QSplitter() self.renderer = Renderer() self.widget.addWidget(self.renderer) - self.widget.addWidget(sidebar.widget) + self.sidebar = SideBarRight() + self.widget.addWidget(self.sidebar.widget) self.widget.setSizes([800, 200]) window.widget.setCentralWidget(self.widget) From a2d3eb914f3a27d62ec279b3ec467aecc8df7d8e Mon Sep 17 00:00:00 2001 From: Licini Date: Tue, 8 Jul 2025 23:48:53 +0200 Subject: [PATCH 11/18] move things --- src/compas_viewer/ui/mainwindow.py | 7 +++++ src/compas_viewer/ui/menubar.py | 9 ++++--- src/compas_viewer/ui/ui.py | 11 +------- src/compas_viewer/ui/viewport.py | 28 +++++++++++++++++++ src/compas_viewer/viewer.py | 43 ++++++++---------------------- 5 files changed, 52 insertions(+), 46 deletions(-) diff --git a/src/compas_viewer/ui/mainwindow.py b/src/compas_viewer/ui/mainwindow.py index e301dda3d0..f7c9525acb 100644 --- a/src/compas_viewer/ui/mainwindow.py +++ b/src/compas_viewer/ui/mainwindow.py @@ -17,3 +17,10 @@ def title(self): def title(self, title: str): self._title = title self.widget.setWindowTitle(title) + + def resize(self, w: int, h: int) -> None: + self.widget.resize(w, h) + rect = self.viewer.app.primaryScreen().availableGeometry() + x = 0.5 * (rect.width() - w) + y = 0.5 * (rect.height() - h) + self.widget.setGeometry(x, y, w, h) diff --git a/src/compas_viewer/ui/menubar.py b/src/compas_viewer/ui/menubar.py index e20330065e..73466e95ce 100644 --- a/src/compas_viewer/ui/menubar.py +++ b/src/compas_viewer/ui/menubar.py @@ -17,12 +17,13 @@ class MenuBar(Component): def __init__(self, window: MainWindow) -> None: super().__init__() self.widget = window.widget.menuBar() + self.load_items() @property def items(self): return self.viewer.config.ui.menubar.items - def add_menu(self, items=None, parent=None) -> list[QAction]: + def load_items(self, items=None, parent=None) -> list[QAction]: items = items or self.items parent = parent or self.widget @@ -55,14 +56,14 @@ def add_menu(self, items=None, parent=None) -> list[QAction]: if items := item.get("items"): if not itemtype or itemtype == "menu": menu = parent.addMenu(text) - self.add_menu(items=items, parent=menu) + self.load_items(items=items, parent=menu) elif itemtype == "group": group = QActionGroup(self.widget) group.setExclusive(item.get("exclusive", True)) menu = parent.addMenu(text) - for i, a in enumerate(self.add_menu(items=items, parent=menu)): + for i, a in enumerate(self.load_items(items=items, parent=menu)): a.setCheckable(True) if i == item.get("selected", 0): a.setChecked(True) @@ -73,7 +74,7 @@ def add_menu(self, items=None, parent=None) -> list[QAction]: else: menu = parent.addMenu(text) - self.add_menu(items=[{"title": "PLACEHOLDER", "action": lambda: print("PLACEHOLDER")}], parent=menu) + self.load_items(items=[{"title": "PLACEHOLDER", "action": lambda: print("PLACEHOLDER")}], parent=menu) return actions diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 4d343a4527..7b0743e824 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -2,7 +2,6 @@ from .mainwindow import MainWindow from .menubar import MenuBar -from .sidebar import SideBarRight from .sidedock import SideDock from .statusbar import StatusBar from .toolbar import ToolBar @@ -23,20 +22,12 @@ def sidebar(self): return self.viewport.sidebar def init(self): - self.resize(self.viewer.config.window.width, self.viewer.config.window.height) + self.window.resize(self.viewer.config.window.width, self.viewer.config.window.height) self.window.widget.show() - self.menubar.add_menu() self.menubar.show = self.viewer.config.ui.menubar.show self.toolbar.show = self.viewer.config.ui.toolbar.show self.sidebar.show = self.viewer.config.ui.sidebar.show self.sidedock.show = self.viewer.config.ui.sidedock.show self.sidebar.update() - - def resize(self, w: int, h: int) -> None: - self.window.widget.resize(w, h) - rect = self.viewer.app.primaryScreen().availableGeometry() - x = 0.5 * (rect.width() - w) - y = 0.5 * (rect.height() - h) - self.window.widget.setGeometry(x, y, w, h) diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/ui/viewport.py index d986d07fe8..bd354b2268 100644 --- a/src/compas_viewer/ui/viewport.py +++ b/src/compas_viewer/ui/viewport.py @@ -17,3 +17,31 @@ def __init__(self, window: MainWindow): self.widget.addWidget(self.sidebar.widget) self.widget.setSizes([800, 200]) window.widget.setCentralWidget(self.widget) + + self._unit = None + self.unit = self.viewer.config.unit + + @property + def unit(self): + return self._unit + + @unit.setter + def unit(self, unit: str): + if self.viewer.running: + raise NotImplementedError("Changing the unit after the viewer is running is not yet supported.") + if unit != self._unit: + previous_scale = self.viewer.config.camera.scale + if unit == "m": + self.viewer.config.renderer.gridsize = (10.0, 10, 10.0, 10) + self.renderer.camera.scale = 1.0 + elif unit == "cm": + self.viewer.config.renderer.gridsize = (1000.0, 10, 1000.0, 10) + self.renderer.camera.scale = 100.0 + elif unit == "mm": + self.viewer.config.renderer.gridsize = (10000.0, 10, 10000.0, 10) + self.renderer.camera.scale = 1000.0 + else: + raise ValueError(f"Invalid unit: {unit}. Valid units are 'm', 'cm', 'mm'.") + self.renderer.camera.distance *= self.renderer.camera.scale / previous_scale + + self._unit = unit diff --git a/src/compas_viewer/viewer.py b/src/compas_viewer/viewer.py index 0ca80c8688..f7cc7ea1ba 100644 --- a/src/compas_viewer/viewer.py +++ b/src/compas_viewer/viewer.py @@ -20,14 +20,9 @@ class Viewer(Singleton): def __init__(self, config: Optional[Config] = None, **kwargs): + self.app = self.create_app() self.running = False - self.app = QApplication(sys.argv) - self.app.setApplicationName("COMPAS Viewer") - self.app.setApplicationDisplayName("COMPAS Viewer") - self.app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "icons", "compas_icon_white.png"))) - self._scene = None - self._unit = "m" self.config = config or Config() self.timer = QTimer() @@ -36,12 +31,6 @@ def __init__(self, config: Optional[Config] = None, **kwargs): self.eventmanager = EventManager() self.ui = UI() - # TODO: move to init - self.unit = self.config.unit - - def init(self): - self.ui.init() - @property def renderer(self) -> Renderer: return self.ui.viewport.renderer @@ -59,34 +48,24 @@ def scene(self, scene: Scene): for obj in self._scene.objects: obj.init() + def create_app(self) -> QApplication: + app = QApplication(sys.argv) + app.setApplicationName("COMPAS Viewer") + app.setApplicationDisplayName("COMPAS Viewer") + app.setWindowIcon(QIcon(os.path.join(HERE, "assets", "icons", "compas_icon_white.png"))) + return app + @property def unit(self) -> str: - return self._unit + return self.ui.viewport.unit @unit.setter def unit(self, unit: str): - if self.running: - raise NotImplementedError("Changing the unit after the viewer is running is not yet supported.") - if unit != self._unit: - previous_scale = self.config.camera.scale - if unit == "m": - self.config.renderer.gridsize = (10.0, 10, 10.0, 10) - self.renderer.camera.scale = 1.0 - elif unit == "cm": - self.config.renderer.gridsize = (1000.0, 10, 1000.0, 10) - self.renderer.camera.scale = 100.0 - elif unit == "mm": - self.config.renderer.gridsize = (10000.0, 10, 10000.0, 10) - self.renderer.camera.scale = 1000.0 - else: - raise ValueError(f"Invalid unit: {unit}. Valid units are 'm', 'cm', 'mm'.") - self.renderer.camera.distance *= self.renderer.camera.scale / previous_scale - - self._unit = unit + self.ui.viewport.unit = unit def show(self): self.running = True - self.init() + self.ui.init() self.app.exec() def on(self, interval: int, frames: Optional[int] = None) -> Callable: From 15205d97367a2e7b7fd6a9a43c782bfbf36a7aa8 Mon Sep 17 00:00:00 2001 From: Li Chen Date: Thu, 10 Jul 2025 18:08:45 +0200 Subject: [PATCH 12/18] update sliders and stuff (still buggy) --- scripts/sidedock.py | 18 +- src/compas_viewer/components/booleantoggle.py | 20 +- .../components/boundcomponent.py | 26 +- src/compas_viewer/components/button.py | 46 +++- src/compas_viewer/components/colorpicker.py | 12 +- src/compas_viewer/components/numberedit.py | 12 +- src/compas_viewer/components/objectsetting.py | 20 +- src/compas_viewer/components/sceneform.py | 16 +- src/compas_viewer/components/slider.py | 222 +++++++++++------- src/compas_viewer/components/textedit.py | 16 +- src/compas_viewer/ui/ui.py | 9 +- 11 files changed, 251 insertions(+), 166 deletions(-) diff --git a/scripts/sidedock.py b/scripts/sidedock.py index bfc9b9e908..48e7426783 100644 --- a/scripts/sidedock.py +++ b/scripts/sidedock.py @@ -10,27 +10,31 @@ boxobj = viewer.scene.add(box) +import time + def toggle_box(): boxobj.show = not boxobj.show viewer.renderer.update() -def slider_changed(slider: Slider, value: int): +def slider_changed1(slider: Slider, value: float): global viewer global boxobj - vmin = slider.min_val - vmax = slider.max_val + boxobj.transformation = Translation.from_vector([5 * value, 0, 0]) + boxobj.update() + viewer.renderer.update() - v = (value - vmin) / (vmax - vmin) +def slider_changed2(slider: Slider, value: float): + global boxobj - boxobj.transformation = Translation.from_vector([10 * v, 0, 0]) boxobj.update() viewer.renderer.update() - viewer.ui.sidedock.show = True viewer.ui.sidedock.add(Button(text="Toggle Box", action=toggle_box)) -viewer.ui.sidedock.add(Slider(title="test", min_val=0, max_val=2, step=0.2, action=slider_changed)) +viewer.ui.sidedock.add(Slider(title="Move Box", min_val=0, max_val=2, step=0.2, action=slider_changed1)) +viewer.ui.sidedock.add(Slider(title="Box Opacity", obj=boxobj, attr="opacity", min_val=0, max_val=1, step=0.1, action=slider_changed2)) + viewer.show() diff --git a/src/compas_viewer/components/booleantoggle.py b/src/compas_viewer/components/booleantoggle.py index f53dca1a94..48e668290a 100644 --- a/src/compas_viewer/components/booleantoggle.py +++ b/src/compas_viewer/components/booleantoggle.py @@ -14,7 +14,7 @@ class BooleanToggle(BoundComponent): """ This component creates a labeled checkbox that can be bound to an object's boolean attribute (either a dictionary key or object attribute). When the checkbox state changes, it automatically - updates the bound attribute and optionally calls a callback function. + updates the bound attribute and optionally calls a action function. Parameters ---------- @@ -24,7 +24,7 @@ class BooleanToggle(BoundComponent): The name of the attribute/key to be edited. title : str, optional The label text to be displayed next to the checkbox. If None, uses the attr name. - callback : Callable[[Component, bool], None], optional + action : Callable[[Component, bool], None], optional A function to call when the checkbox state changes. Receives the component and new boolean value. Attributes @@ -33,8 +33,8 @@ class BooleanToggle(BoundComponent): The object or dictionary containing the boolean attribute being edited. attr : str The name of the attribute/key being edited. - callback : Callable[[Component, bool], None] or None - The callback function to call when the checkbox state changes. + action : Callable[[Component, bool], None] or None + The action function to call when the checkbox state changes. widget : QWidget The main widget containing the layout. layout : QHBoxLayout @@ -58,9 +58,9 @@ def __init__( obj: Union[object, dict], attr: str, title: str = None, - callback: Callable[[Component, bool], None] = None, + action: Callable[[Component, bool], None] = None, ): - super().__init__(obj, attr, callback=callback) + super().__init__(obj, attr, action=action) self.widget = QWidget() self.layout = QHBoxLayout() @@ -80,13 +80,13 @@ def __init__( self.layout.addWidget(self.checkbox) self.widget.setLayout(self.layout) - # Connect the checkbox state change signal to the callback + # Connect the checkbox state change signal to the action self.checkbox.stateChanged.connect(self.on_state_changed) def on_state_changed(self, state): - """Handle checkbox state change events by updating the bound attribute and calling the callback.""" + """Handle checkbox state change events by updating the bound attribute and calling the action.""" # Convert Qt checkbox state to boolean is_checked = state == 2 # Qt.Checked = 2 self.set_attr(is_checked) - if self.callback: - self.callback(self, is_checked) + if self.action: + self.action(self, is_checked) diff --git a/src/compas_viewer/components/boundcomponent.py b/src/compas_viewer/components/boundcomponent.py index a3012e03a3..38412d9f64 100644 --- a/src/compas_viewer/components/boundcomponent.py +++ b/src/compas_viewer/components/boundcomponent.py @@ -11,7 +11,7 @@ class BoundComponent(Component): This class provides common functionality for UI components that need to be bound to an attribute of an object or dictionary. It handles getting and setting values - from the bound attribute and provides a callback mechanism for when values change. + from the bound attribute and provides a action mechanism for when values change. Parameters ---------- @@ -19,7 +19,7 @@ class BoundComponent(Component): The object or dictionary containing the attribute to be bound. attr : str The name of the attribute/key to be bound. - callback : Callable[[Component, float], None] + action : Callable[[Component, float], None] A function to call when the value changes. Receives the component and new value. Attributes @@ -28,28 +28,28 @@ class BoundComponent(Component): The object or dictionary containing the attribute being bound. attr : str The name of the attribute/key being bound. - callback : Callable[[Component, float], None] - The callback function to call when the value changes. + action : Callable[[Component, float], None] + The action function to call when the value changes. Example ------- >>> class MyObject: ... def __init__(self): ... self.value = 10.0 - >>> def my_callback(component, value): + >>> def my_action(component, value): ... print(f"Value changed to: {value}") >>> obj = MyObject() - >>> component = BoundComponent(obj, "value", my_callback) + >>> component = BoundComponent(obj, "value", my_action) >>> component.set_attr(20.0) >>> print(component.get_attr()) # prints 20.0 """ - def __init__(self, obj: Union[object, dict], attr: str, callback: Callable[[Component, float], None]): + def __init__(self, obj: Union[object, dict], attr: str, action: Callable[[Component, float], None]): super().__init__() self.obj = obj self.attr = attr - self.callback = callback + self.action = action def get_attr(self): """ @@ -60,6 +60,8 @@ def get_attr(self): float The current value of the attribute. """ + if self.obj is None or self.attr is None: + return None if isinstance(self.obj, dict): return self.obj[self.attr] else: @@ -74,6 +76,8 @@ def set_attr(self, value: float): value : float The new value to set. """ + if self.obj is None or self.attr is None: + return if isinstance(self.obj, dict): self.obj[self.attr] = value else: @@ -84,7 +88,7 @@ def on_value_changed(self, value: Any): Handle value changes for the bound attribute. This method is called when the component's value changes. It updates the bound - attribute and calls the callback function if one was provided. + attribute and calls the action function if one was provided. Parameters ---------- @@ -92,5 +96,5 @@ def on_value_changed(self, value: Any): The new value to set. """ self.set_attr(value) - if self.callback is not None: - self.callback(self, value) + if self.action is not None: + self.action(self, value) diff --git a/src/compas_viewer/components/button.py b/src/compas_viewer/components/button.py index 78b18b11dc..d510ecca2c 100644 --- a/src/compas_viewer/components/button.py +++ b/src/compas_viewer/components/button.py @@ -7,29 +7,61 @@ from PySide6 import QtGui from PySide6 import QtWidgets +from .component import Component + def set_icon_path(icon_name: str) -> str: path = QtGui.QIcon(str(pathlib.Path(__file__).parent.parent / "assets" / "icons" / icon_name)) return path -class Button(QtWidgets.QPushButton): +class Button(Component): + """ + This component creates a button widget that can be customized with text, icon, tooltip, and action. + + Parameters + ---------- + text : str, optional + The text to display on the button. + icon_path : Union[str, pathlib.Path], optional + The path to the icon file to display on the button. + tooltip : str, optional + The tooltip text to show when hovering over the button. + action : Callable[[], None], optional + A function to call when the button is clicked. + + Attributes + ---------- + widget : QPushButton + The push button widget. + action : Callable[[], None] or None + The callback function to call when the button is clicked. + + Example + ------- + >>> def my_action(): + ... print("Button clicked!") + >>> component = Button(text="Click Me", action=my_action) + """ + def __init__( self, text: Optional[str] = None, icon_path: Optional[Union[str, pathlib.Path]] = None, tooltip: Optional[str] = None, action: Optional[Callable[[], None]] = None, - parent=None, ): super().__init__() + self.action = action + self.widget = QtWidgets.QPushButton() + if text: - self.setText(text) + self.widget.setText(text) if icon_path: - self.setIcon(QtGui.QIcon(set_icon_path(icon_path))) - self.setIconSize(QtCore.QSize(17, 17)) + self.widget.setIcon(QtGui.QIcon(set_icon_path(icon_path))) + self.widget.setIconSize(QtCore.QSize(17, 17)) if tooltip: - self.setToolTip(tooltip) + self.widget.setToolTip(tooltip) if action: - self.clicked.connect(action) + self.widget.clicked.connect(action) diff --git a/src/compas_viewer/components/colorpicker.py b/src/compas_viewer/components/colorpicker.py index 98d36a0afa..34d77a5e1e 100644 --- a/src/compas_viewer/components/colorpicker.py +++ b/src/compas_viewer/components/colorpicker.py @@ -39,7 +39,7 @@ class ColorPicker(BoundComponent): """ This component creates a labeled color picker button that can be bound to an object's color attribute (either a dictionary key or object attribute). When the color changes, it automatically - updates the bound attribute and optionally calls a callback function. + updates the bound attribute and optionally calls a action function. Parameters ---------- @@ -49,7 +49,7 @@ class ColorPicker(BoundComponent): The name of the attribute/key to be edited. title : str, optional The label text to be displayed next to the color picker. If None, uses the attr name. - callback : Callable[[Component, Color], None], optional + action : Callable[[Component, Color], None], optional A function to call when the color changes. Receives the component and new color value. Attributes @@ -58,8 +58,8 @@ class ColorPicker(BoundComponent): The object or dictionary containing the color attribute being edited. attr : str The name of the attribute/key being edited. - callback : Callable[[Component, Color], None] or None - The callback function to call when the color changes. + action : Callable[[Component, Color], None] or None + The action function to call when the color changes. widget : QWidget The main widget containing the layout. layout : QHBoxLayout @@ -85,9 +85,9 @@ def __init__( obj: Union[object, dict], attr: str, title: str = None, - callback: Callable[[Component, Color], None] = None, + action: Callable[[Component, Color], None] = None, ): - super().__init__(obj, attr, callback=callback) + super().__init__(obj, attr, action=action) self.widget = QWidget() self.layout = QHBoxLayout() diff --git a/src/compas_viewer/components/numberedit.py b/src/compas_viewer/components/numberedit.py index 1e3e29c01e..b29e18a144 100644 --- a/src/compas_viewer/components/numberedit.py +++ b/src/compas_viewer/components/numberedit.py @@ -14,7 +14,7 @@ class NumberEdit(BoundComponent): """ This component creates a labeled number spin box that can be bound to an object's attribute (either a dictionary key or object attribute). When the value changes, it automatically - updates the bound attribute and optionally calls a callback function. + updates the bound attribute and optionally calls a action function. Parameters ---------- @@ -32,7 +32,7 @@ class NumberEdit(BoundComponent): The step size for the spin box. Defaults to 0.1. decimals : int, optional The number of decimal places to display. Defaults to 1. - callback : Callable[[Component, float], None], optional + action : Callable[[Component, float], None], optional A function to call when the value changes. Receives the component and new value. Attributes @@ -41,8 +41,8 @@ class NumberEdit(BoundComponent): The object or dictionary containing the attribute being edited. attr : str The name of the attribute/key being edited. - callback : Callable[[Component, float], None] or None - The callback function to call when the value changes. + action : Callable[[Component, float], None] or None + The action function to call when the value changes. widget : QWidget The main widget containing the layout. layout : QHBoxLayout @@ -70,9 +70,9 @@ def __init__( max_val: float = None, step: float = 0.1, decimals: int = 1, - callback: Callable[[Component, float], None] = None, + action: Callable[[Component, float], None] = None, ): - super().__init__(obj, attr, callback=callback) + super().__init__(obj, attr, action=action) self.widget = QWidget() self.layout = QHBoxLayout() diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index 9b0db5d1b5..aa1f2d3732 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -45,35 +45,35 @@ def _update_sceneform(*arg): self.viewer.ui.sidebar.sceneform.update(refresh=True) if hasattr(obj, "name"): - name_edit = TextEdit(obj, "name", callback=_update_sceneform) + name_edit = TextEdit(obj, "name", action=_update_sceneform) self.add(name_edit) if hasattr(obj, "show_points"): - self.add(BooleanToggle(obj=obj, attr="show_points", callback=_update_obj_settings)) + self.add(BooleanToggle(obj=obj, attr="show_points", action=_update_obj_settings)) if hasattr(obj, "show_lines"): - self.add(BooleanToggle(obj=obj, attr="show_lines", callback=_update_obj_settings)) + self.add(BooleanToggle(obj=obj, attr="show_lines", action=_update_obj_settings)) if hasattr(obj, "show_faces"): - self.add(BooleanToggle(obj=obj, attr="show_faces", callback=_update_obj_settings)) + self.add(BooleanToggle(obj=obj, attr="show_faces", action=_update_obj_settings)) if hasattr(obj, "pointcolor"): - self.add(ColorPicker(obj=obj, attr="pointcolor", callback=_update_obj_settings)) + self.add(ColorPicker(obj=obj, attr="pointcolor", action=_update_obj_settings)) if hasattr(obj, "linecolor"): - self.add(ColorPicker(obj=obj, attr="linecolor", callback=_update_obj_settings)) + self.add(ColorPicker(obj=obj, attr="linecolor", action=_update_obj_settings)) if hasattr(obj, "facecolor"): - self.add(ColorPicker(obj=obj, attr="facecolor", callback=_update_obj_settings)) + self.add(ColorPicker(obj=obj, attr="facecolor", action=_update_obj_settings)) if hasattr(obj, "linewidth"): - linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, callback=_update_obj_settings) + linewidth_edit = NumberEdit(obj, "linewidth", title="line width", min_val=0.0, max_val=10.0, action=_update_obj_settings) self.add(linewidth_edit) if hasattr(obj, "pointsize"): - pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, callback=_update_obj_settings) + pointsize_edit = NumberEdit(obj, "pointsize", title="point size", min_val=0.0, max_val=10.0, action=_update_obj_settings) self.add(pointsize_edit) if hasattr(obj, "opacity"): - opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, callback=_update_obj_settings) + opacity_edit = NumberEdit(obj, "opacity", title="opacity", min_val=0.0, max_val=1.0, action=_update_obj_settings) self.add(opacity_edit) diff --git a/src/compas_viewer/components/sceneform.py b/src/compas_viewer/components/sceneform.py index 4d19b8645e..8817b2a8e7 100644 --- a/src/compas_viewer/components/sceneform.py +++ b/src/compas_viewer/components/sceneform.py @@ -23,8 +23,8 @@ class Sceneform(Component): A list of booleans indicating whether the corresponding column is editable. Defaults to [False]. show_headers : bool, optional Show the header of the tree. Defaults to True. - callback : Callable, optional - Callback function to execute when an item is clicked or selected. + action : Callable, optional + action function to execute when an item is clicked or selected. Attributes ---------- @@ -43,7 +43,7 @@ def __init__( columns: list[dict], column_editable: Optional[list[bool]] = None, show_headers: bool = True, - callback: Optional[Callable] = None, + action: Optional[Callable] = None, ): super().__init__() @@ -57,7 +57,7 @@ def __init__( self.widget.setSelectionMode(QTreeWidget.SingleSelection) self._sceneobjects = [] - self.callback = callback + self.action = action self.widget.itemClicked.connect(self.on_item_clicked) self.widget.itemSelectionChanged.connect(self.on_item_selection_changed) @@ -130,8 +130,8 @@ def on_item_clicked(self, item, column): selected_nodes = {item.node for item in self.widget.selectedItems()} for node in self.scene.objects: node.is_selected = node in selected_nodes - if self.callback and node.is_selected: - self.callback(self, node) + if self.action and node.is_selected: + self.action(self, node) self.viewer.ui.sidebar.update() @@ -139,8 +139,8 @@ def on_item_clicked(self, item, column): def on_item_selection_changed(self): for item in self.widget.selectedItems(): - if self.callback: - self.callback(self, item.node) + if self.action: + self.action(self, item.node) def adjust_column_widths(self): for i in range(self.widget.columnCount()): diff --git a/src/compas_viewer/components/slider.py b/src/compas_viewer/components/slider.py index 1b085f9102..dbd241c764 100644 --- a/src/compas_viewer/components/slider.py +++ b/src/compas_viewer/components/slider.py @@ -1,88 +1,124 @@ from typing import Callable from typing import Optional +from typing import Union from PySide6.QtCore import Qt from PySide6.QtWidgets import QHBoxLayout from PySide6.QtWidgets import QLabel +from PySide6.QtWidgets import QLineEdit from PySide6.QtWidgets import QSlider from PySide6.QtWidgets import QVBoxLayout from PySide6.QtWidgets import QWidget -from compas_viewer.components.textedit import TextEdit +from .boundcomponent import BoundComponent +from .component import Component + + +class Slider(BoundComponent): + """ + This component creates a customizable slider widget that can be bound to an object's attribute + (either a dictionary key or object attribute). When the value changes, it automatically + updates the bound attribute and optionally calls a action function. + + Parameters + ---------- + obj : Union[object, dict], optional + The object or dictionary containing the attribute to be edited. If None, the slider + operates with the provided value parameter. + attr : str, optional + The name of the attribute/key to be edited. If None, the slider operates with the + provided value parameter. + value : float, optional + The initial value for the slider when not bound to an object attribute. Defaults to 0. + title : str, optional + Label displayed above the slider. If None, uses the attr name or "Value" if attr is None. + min_val : float, optional + Minimum value of the slider. Defaults to 0. + max_val : float, optional + Maximum value of the slider. Defaults to 100. + step : float, optional + Step size of the slider. Defaults to 1. + tick_interval : float, optional + Interval between tick marks. No ticks if None. Defaults to None. + action : Callable[[Component, float], None], optional + A function to call when the value changes. Receives the component and new value. + + Attributes + ---------- + obj : Union[object, dict] + The object or dictionary containing the attribute being edited. + attr : str + The name of the attribute/key being edited. + action : Callable[[Component, float], None] or None + The action function to call when the value changes. + widget : QWidget + The main widget containing the slider layout. + title : str + Label displayed above the slider. + min_val : float + Minimum value of the slider. + max_val : float + Maximum value of the slider. + step : float + Step size of the slider. + layout : QVBoxLayout + Layout of the widget. + slider : QSlider + Slider widget. + value_label : QLabel + Label displaying the current value of the slider. + line_edit : QLineEdit + Text input field for direct value entry. + + Example + ------- + >>> # Bound to an object attribute + >>> class MyObject: + ... def __init__(self): + ... self.brightness = 50.0 + >>> obj = MyObject() + >>> def my_action(component, value): + ... print(f"Brightness changed to: {value}") + >>> component = Slider(obj, "brightness", title="Brightness", min_val=0, max_val=100, action=my_action) + + >>> # Standalone slider with initial value + >>> standalone_slider = Slider(value=25.0, title="Volume", min_val=0, max_val=100) + """ - -class Slider(QWidget): def __init__( self, - title: str = "Slider", + obj: Union[object, dict] = None, + attr: str = None, + value: float = 0, + title: str = None, min_val: float = 0, max_val: float = 100, - step: Optional[float] = 1, - action: Callable = None, - starting_val: Optional[float] = None, + step: float = 1, tick_interval: Optional[float] = None, + action: Callable[[Component, float], None] = None, ): - """ - A customizable slider widget for Qt applications, supporting both horizontal and vertical orientations. This - widget displays the current, minimum, and maximum values and allows for dynamic user interaction. - - Parameters - ---------- - title : str - Label displayed above the slider, defaults to "Slider". - min_val : float - Minimum value of the slider, defaults to 0. - max_val : float - Maximum value of the slider, defaults to 100. - step : float, optional - Step size of the slider. Defaults to 1. - action : Callable - Function to execute on value change. Should accept a single integer argument. - horizontal : bool, optional - Orientation of the slider. True for horizontal, False for vertical. Defaults to True. - starting_val : float, optional - Initial value of the slider, defaults to the minimum value. - tick_interval : float, optional - Interval between tick marks. No ticks if None. Defaults to None. - - Attributes - ---------- - title : str - Label displayed above the slider. - min_val : float - Minimum value of the slider. - max_val : float - Maximum value of the slider. - step : float - Step size of the slider. - action : Callable - Function to execute on value change. - start_val : float - Initial value of the slider. - layout : QVBoxLayout - Layout of the widget. - slider : QSlider - Slider widget. - value_label : QLabel - Label displaying the current value of the slider. - - Example - ------- - >>> slider = Slider("Brightness", min_val=0, max_val=255, starting_val=100) - """ - super().__init__() - self.title = title - self.action = action + super().__init__(obj, attr, action=action) + + self.title = title if title is not None else (attr if attr is not None else "Value") 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.step = step self._tick_interval = tick_interval if tick_interval is not None else (self._scaled_max_val - self._scaled_min_val) / 10 self._updating = False - self._default_layout = None - self.layout = self.default_layout + # Determine the initial value consistently + if obj is not None and attr is not None: + initial_value = self.get_attr() + else: + initial_value = value + + # Clamp initial value to valid range + initial_value = max(self.min_val, min(self.max_val, initial_value)) + + # Create the main widget and layout + self.widget = QWidget() + self.layout = QVBoxLayout() self._text_layout = QHBoxLayout() self._domain_layout = QHBoxLayout() @@ -91,19 +127,20 @@ def __init__( 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) + self.slider.setValue(self._scale_value(initial_value)) + # Connect the slider movement to the action + self.slider.valueChanged.connect(self.on_slider_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) + self.line_edit = QLineEdit(str(initial_value)) + self.line_edit.setMaximumSize(85, 25) + self.line_edit.textChanged.connect(self.on_text_changed) self._text_layout.addWidget(self.value_label) - self._text_layout.addWidget(self.text_edit.text_edit) + self._text_layout.addWidget(self.line_edit) # Add widgets to layout self._domain_layout.addWidget(self._min_label) @@ -112,43 +149,50 @@ def __init__( 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 + self.widget.setLayout(self.layout) @property def _scaled_min_val(self): - return self.min_val / self.step + return int(self.min_val / self.step) @property def _scaled_max_val(self): - return self.max_val / self.step + return int(self.max_val / self.step) + + def _scale_value(self, value: float) -> int: + """Scale a real value to slider integer value.""" + return round(value / self.step) + + def _unscale_value(self, scaled_value: int) -> float: + """Unscale a slider integer value to real value.""" + return round(scaled_value * self.step, 2) - def on_value_changed(self, value): + def on_slider_changed(self, scaled_value: int): + """Handle slider value changes.""" 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) + real_value = self._unscale_value(scaled_value) + self.line_edit.setText(str(real_value)) + self.on_value_changed(real_value) self._updating = False - def text_update(self): + def on_text_changed(self): + """Handle text input changes.""" 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) + value = float(self.line_edit.text()) + # Clamp value to valid range + clamped_value = max(self.min_val, min(self.max_val, value)) + + # Update the line edit if the value was clamped + if clamped_value != value: + self.line_edit.setText(str(clamped_value)) + + self.slider.setValue(self._scale_value(clamped_value)) + self.on_value_changed(clamped_value) 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 index cdfc8e7aa9..746fb5d3a8 100644 --- a/src/compas_viewer/components/textedit.py +++ b/src/compas_viewer/components/textedit.py @@ -16,7 +16,7 @@ class TextEdit(BoundComponent): """ This component creates a labeled text edit widget that can be bound to an object's attribute (either a dictionary key or object attribute). When the text changes, it automatically - updates the bound attribute and optionally calls a callback function. + updates the bound attribute and optionally calls a action function. Parameters ---------- @@ -26,7 +26,7 @@ class TextEdit(BoundComponent): The name of the attribute/key to be edited. title : str, optional The label text to be displayed next to the text edit. If None, uses the attr name. - callback : Callable[[Component, str], None], optional + action : Callable[[Component, str], None], optional A function to call when the text changes. Receives the component and new text value. Attributes @@ -35,8 +35,8 @@ class TextEdit(BoundComponent): The object or dictionary containing the attribute being edited. attr : str The name of the attribute/key being edited. - callback : Callable[[Component, str], None] or None - The callback function to call when the text changes. + action : Callable[[Component, str], None] or None + The action function to call when the text changes. widget : QWidget The main widget containing the layout. layout : QHBoxLayout @@ -60,9 +60,9 @@ def __init__( obj: Union[object, dict], attr: str, title: str = None, - callback: Callable[[Component, str], None] = None, + action: Callable[[Component, str], None] = None, ): - super().__init__(obj, attr, callback=callback) + super().__init__(obj, attr, action=action) self.widget = QWidget() self.layout = QHBoxLayout() @@ -79,10 +79,10 @@ def __init__( self.layout.addWidget(self.text_edit) self.widget.setLayout(self.layout) - # Connect the text change signal to the callback + # Connect the text change signal to the action self.text_edit.textChanged.connect(self.on_text_changed) def on_text_changed(self): - """Handle text change events by updating the bound attribute and calling the callback.""" + """Handle text change events by updating the bound attribute and calling the action.""" new_text = self.text_edit.toPlainText() self.on_value_changed(new_text) diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 7b0743e824..95784466c2 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -17,6 +17,11 @@ def __init__(self) -> None: self.sidedock = SideDock(self.window) self.viewport = ViewPort(self.window) + self.menubar.show = self.viewer.config.ui.menubar.show + self.toolbar.show = self.viewer.config.ui.toolbar.show + self.sidebar.show = self.viewer.config.ui.sidebar.show + self.sidedock.show = self.viewer.config.ui.sidedock.show + @property def sidebar(self): return self.viewport.sidebar @@ -25,9 +30,5 @@ def init(self): self.window.resize(self.viewer.config.window.width, self.viewer.config.window.height) self.window.widget.show() - self.menubar.show = self.viewer.config.ui.menubar.show - self.toolbar.show = self.viewer.config.ui.toolbar.show - self.sidebar.show = self.viewer.config.ui.sidebar.show - self.sidedock.show = self.viewer.config.ui.sidedock.show self.sidebar.update() From d2d1a8e4a54e01cc51d9706c31d2efd39956cb9a Mon Sep 17 00:00:00 2001 From: Licini Date: Wed, 16 Jul 2025 16:00:10 +0200 Subject: [PATCH 13/18] update camera settings --- src/compas_viewer/commands.py | 14 +- src/compas_viewer/components/__init__.py | 6 +- src/compas_viewer/components/camerasetting.py | 182 ++++++++++----- src/compas_viewer/components/container.py | 8 + src/compas_viewer/components/numberedit.py | 4 + src/compas_viewer/components/slider.py | 10 +- src/compas_viewer/components/tabform.py | 213 ++++++++++++++++++ src/compas_viewer/config.py | 25 +- src/compas_viewer/ui/sidebar.py | 26 ++- src/compas_viewer/ui/ui.py | 1 - 10 files changed, 398 insertions(+), 91 deletions(-) create mode 100644 src/compas_viewer/components/tabform.py diff --git a/src/compas_viewer/commands.py b/src/compas_viewer/commands.py index 115cb08bd7..866075fb99 100644 --- a/src/compas_viewer/commands.py +++ b/src/compas_viewer/commands.py @@ -21,7 +21,6 @@ from compas.datastructures import Datastructure from compas.geometry import Geometry from compas.scene import Scene -from compas_viewer.components.camerasetting import CameraSettingsDialog if TYPE_CHECKING: from compas_viewer import Viewer @@ -115,8 +114,17 @@ def change_view(viewer: "Viewer", mode: Literal["Perspective", "Top", "Front", " def camera_settings(viewer: "Viewer"): - items = viewer.config.camera.dialog_settings - CameraSettingsDialog(items=items).exec() + # Try to focus on the camera settings tab in the sidebar + if hasattr(viewer.ui, "sidebar") and hasattr(viewer.ui.sidebar, "tabform"): + tabform = viewer.ui.sidebar.tabform + if "Camera" in tabform.tabs: + tabform.set_current_tab("Camera") + if hasattr(viewer.ui.sidebar, "camera_setting"): + viewer.ui.sidebar.camera_setting.update() + else: + print("Camera settings tab not found in sidebar") + else: + print("Camera settings are available in the sidebar") camera_settings_cmd = Command(title="Camera Settings", callback=camera_settings) diff --git a/src/compas_viewer/components/__init__.py b/src/compas_viewer/components/__init__.py index a22d5f8aa1..7e505c3162 100644 --- a/src/compas_viewer/components/__init__.py +++ b/src/compas_viewer/components/__init__.py @@ -1,24 +1,26 @@ from .button import Button from .combobox import ComboBox from .combobox import ViewModeAction -from .camerasetting import CameraSettingsDialog +from .camerasetting import CameraSetting from .slider import Slider from .textedit import TextEdit from .treeform import Treeform from .sceneform import Sceneform from .objectsetting import ObjectSetting +from .tabform import Tabform from .component import Component __all__ = [ "Button", "ComboBox", - "CameraSettingsDialog", + "CameraSetting", "Renderer", "Slider", "TextEdit", "Treeform", "Sceneform", "ObjectSetting", + "Tabform", "Component", "ViewModeAction", ] diff --git a/src/compas_viewer/components/camerasetting.py b/src/compas_viewer/components/camerasetting.py index eb0f410b2a..a43ffb555f 100644 --- a/src/compas_viewer/components/camerasetting.py +++ b/src/compas_viewer/components/camerasetting.py @@ -1,68 +1,146 @@ -from PySide6.QtWidgets import QDialog -from PySide6.QtWidgets import QPushButton -from PySide6.QtWidgets import QVBoxLayout +from .button import Button +from .container import Container +from .numberedit import NumberEdit -from compas_viewer.base import Base -# from compas_viewer.components.layout import SettingLayout - - -class CameraSettingsDialog(QDialog, Base): +class CameraSetting(Container): """ - A dialog for displaying and updating camera settings in Qt applications. - 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. + A form component for displaying and updating camera settings in the viewer. + This component allows users to modify the camera's target and position + with real-time updates. Attributes ---------- - layout : QVBoxLayout - The layout of the dialog. - spin_boxes : dict - Dictionary containing spin boxes for adjusting camera settings. - update_button : QPushButton - Button to apply changes to the camera settings. - camera : Camera - The camera object from the viewer's renderer. - - Methods - ------- - update() - Updates the camera's target and position and closes the dialog. + target_x : NumberEdit + Number editor for camera target X coordinate. + target_y : NumberEdit + Number editor for camera target Y coordinate. + target_z : NumberEdit + Number editor for camera target Z coordinate. + position_x : NumberEdit + Number editor for camera position X coordinate. + position_y : NumberEdit + Number editor for camera position Y coordinate. + position_z : NumberEdit + Number editor for camera position Z coordinate. + fov : NumberEdit + Number editor for camera field of view. + near : NumberEdit + Number editor for camera near plane. + far : NumberEdit + Number editor for camera far plane. + scale : NumberEdit + Number editor for camera scale. + zoomdelta : NumberEdit + Number editor for camera zoom delta. + rotationdelta : NumberEdit + Number editor for camera rotation delta. + pandelta : NumberEdit + Number editor for camera pan delta. + reset_button : Button + Button to reset camera to default position. Example ------- - >>> dialog = CameraSettingsDialog(items=items) - >>> dialog.exec() + >>> camera_setting = CameraSetting() + >>> camera_setting.update() """ - def __init__(self, items: list[dict]) -> None: - super().__init__() - self.setWindowTitle("Camera Settings") + def __init__(self) -> None: + super().__init__(container_type="scrollable") + self.populate() + + def populate(self) -> None: + """Populate the form with camera setting components.""" + self.reset() + + if not hasattr(self.viewer, "ui"): + # If the ui is not initialized, don't populate the form + return + + def _update_camera(*args): + self.viewer.renderer.update() + + camera = self.viewer.renderer.camera - self.layout = QVBoxLayout(self) - # self.setting_layout = SettingLayout(viewer=self.viewer, items=items, type="camera_setting") - # self.setting_layout.generate_layout() + # Create NumberEdit components for target (bind directly to camera.target) + self.target_x = NumberEdit(camera.target, "x", title="Target X", action=_update_camera) + self.target_y = NumberEdit(camera.target, "y", title="Target Y", action=_update_camera) + self.target_z = NumberEdit(camera.target, "z", title="Target Z", action=_update_camera) - self.layout.addLayout(self.setting_layout.layout) + # Create NumberEdit components for position (bind directly to camera.position) + self.position_x = NumberEdit(camera.position, "x", title="Position X", action=_update_camera) + self.position_y = NumberEdit(camera.position, "y", title="Position Y", action=_update_camera) + self.position_z = NumberEdit(camera.position, "z", title="Position Z", action=_update_camera) - self.update_button = QPushButton("Update Camera", self) - self.update_button.clicked.connect(self.update) - self.layout.addWidget(self.update_button) + # Create NumberEdit components for camera properties (bind directly to camera) + self.fov = NumberEdit(camera, "fov", title="Field of View", step=1.0, min_val=1.0, max_val=179.0, action=_update_camera) + self.near = NumberEdit(camera, "near", title="Near Plane", action=_update_camera) + self.far = NumberEdit(camera, "far", title="Far Plane", min_val=10.0, max_val=10000.0, action=_update_camera) + self.scale = NumberEdit(camera, "scale", title="Scale", min_val=0.1, max_val=10.0, action=_update_camera) + self.zoomdelta = NumberEdit(camera, "zoomdelta", title="Zoom Delta", min_val=0.001, max_val=1.0, action=_update_camera) + self.rotationdelta = NumberEdit(camera, "rotationdelta", title="Rotation Delta", min_val=0.001, max_val=1.0, action=_update_camera) + self.pandelta = NumberEdit(camera, "pandelta", title="Pan Delta", min_val=0.001, max_val=1.0, action=_update_camera) + + # Add components to the form + self.add(self.target_x) + self.add(self.target_y) + self.add(self.target_z) + self.add(self.position_x) + self.add(self.position_y) + self.add(self.position_z) + self.add(self.fov) + self.add(self.near) + self.add(self.far) + self.add(self.scale) + self.add(self.zoomdelta) + self.add(self.rotationdelta) + self.add(self.pandelta) + + # Add reset button + def _reset_camera(): + """Reset camera to default settings.""" + camera = self.viewer.renderer.camera + config = self.viewer.config.camera + + # Reset all camera properties to config defaults + camera.target.set(*config.target) + camera.position.set(*config.position) + camera.fov = config.fov + camera.near = config.near + camera.far = config.far + camera.scale = config.scale + camera.zoomdelta = config.zoomdelta + camera.rotationdelta = config.rotationdelta + camera.pandelta = config.pandelta + + self.viewer.renderer.update() + # Update the form values + self.update() + + self.reset_button = Button(text="Reset to Defaults", action=_reset_camera) + self.add(self.reset_button) def update(self) -> None: - self.viewer.renderer.camera.target.set( - 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.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() + """Update the form with current camera settings.""" + self.populate() + + camera = self.viewer.renderer.camera + + # Update the NumberEdit components + if hasattr(self, "target_x"): + self.target_x.spinbox.setValue(camera.target.x) + self.target_y.spinbox.setValue(camera.target.y) + self.target_z.spinbox.setValue(camera.target.z) + + self.position_x.spinbox.setValue(camera.position.x) + self.position_y.spinbox.setValue(camera.position.y) + self.position_z.spinbox.setValue(camera.position.z) + + self.fov.spinbox.setValue(camera.fov) + self.near.spinbox.setValue(camera.near) + self.far.spinbox.setValue(camera.far) + self.scale.spinbox.setValue(camera.scale) + self.zoomdelta.spinbox.setValue(camera.zoomdelta) + self.rotationdelta.spinbox.setValue(camera.rotationdelta) + self.pandelta.spinbox.setValue(camera.pandelta) diff --git a/src/compas_viewer/components/container.py b/src/compas_viewer/components/container.py index cc51e8449f..fea5ec37d3 100644 --- a/src/compas_viewer/components/container.py +++ b/src/compas_viewer/components/container.py @@ -56,9 +56,12 @@ def __init__(self, container_type=None): if self.container_type == "scrollable": self.widget = QScrollArea() self.widget.setWidgetResizable(True) + self.widget.setContentsMargins(0, 0, 0, 0) self._scroll_content = QWidget() + self._scroll_content.setContentsMargins(0, 0, 0, 0) self._scroll_layout = QVBoxLayout(self._scroll_content) self._scroll_layout.setAlignment(Qt.AlignTop) + self._scroll_layout.setContentsMargins(0, 0, 0, 0) self.widget.setWidget(self._scroll_content) self.layout = QVBoxLayout() self.layout.setSpacing(0) @@ -67,9 +70,11 @@ def __init__(self, container_type=None): elif self.container_type == "splitter": self.widget = QSplitter(Qt.Orientation.Vertical) self.widget.setChildrenCollapsible(True) + self.widget.setContentsMargins(0, 0, 0, 0) self.layout = None # Splitter doesn't use layout else: self.widget = QWidget() + self.widget.setContentsMargins(0, 0, 0, 0) self.layout = QVBoxLayout() self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) @@ -77,6 +82,9 @@ def __init__(self, container_type=None): def add(self, component: "Component") -> None: """Add a component to the container.""" + if component in self.children: + return + if self.container_type == "splitter": self.widget.addWidget(component.widget) child_count = self.widget.count() diff --git a/src/compas_viewer/components/numberedit.py b/src/compas_viewer/components/numberedit.py index b29e18a144..34811f7e5f 100644 --- a/src/compas_viewer/components/numberedit.py +++ b/src/compas_viewer/components/numberedit.py @@ -88,8 +88,12 @@ def __init__( if min_val is not None: self.spinbox.setMinimum(min_val) + else: + self.spinbox.setMinimum(-float("inf")) if max_val is not None: self.spinbox.setMaximum(max_val) + else: + self.spinbox.setMaximum(float("inf")) self.layout.addWidget(self.label) self.layout.addWidget(self.spinbox) diff --git a/src/compas_viewer/components/slider.py b/src/compas_viewer/components/slider.py index dbd241c764..d45228f6e2 100644 --- a/src/compas_viewer/components/slider.py +++ b/src/compas_viewer/components/slider.py @@ -80,7 +80,7 @@ class Slider(BoundComponent): >>> def my_action(component, value): ... print(f"Brightness changed to: {value}") >>> component = Slider(obj, "brightness", title="Brightness", min_val=0, max_val=100, action=my_action) - + >>> # Standalone slider with initial value >>> standalone_slider = Slider(value=25.0, title="Volume", min_val=0, max_val=100) """ @@ -98,7 +98,7 @@ def __init__( action: Callable[[Component, float], None] = None, ): super().__init__(obj, attr, action=action) - + self.title = title if title is not None else (attr if attr is not None else "Value") self.min_val = min_val self.max_val = max_val @@ -112,7 +112,7 @@ def __init__( initial_value = self.get_attr() else: initial_value = value - + # Clamp initial value to valid range initial_value = max(self.min_val, min(self.max_val, initial_value)) @@ -186,11 +186,11 @@ def on_text_changed(self): value = float(self.line_edit.text()) # Clamp value to valid range clamped_value = max(self.min_val, min(self.max_val, value)) - + # Update the line edit if the value was clamped if clamped_value != value: self.line_edit.setText(str(clamped_value)) - + self.slider.setValue(self._scale_value(clamped_value)) self.on_value_changed(clamped_value) except ValueError: diff --git a/src/compas_viewer/components/tabform.py b/src/compas_viewer/components/tabform.py new file mode 100644 index 0000000000..31aa0bf77a --- /dev/null +++ b/src/compas_viewer/components/tabform.py @@ -0,0 +1,213 @@ +from typing import Optional + +from PySide6.QtWidgets import QTabWidget + +from .component import Component +from .container import Container + + +class Tabform(Component): + """ + A component to create a tabbed interface using QTabWidget. + + This component follows the same pattern as ObjectSetting but provides + a tabbed interface where each tab can contain different content. + + Parameters + ---------- + tab_position : str, optional + Position of the tabs. Options are "top", "bottom", "left", "right". + Defaults to "top". + + Attributes + ---------- + widget : QTabWidget + The tab widget that contains all tabs. + tabs : dict + Dictionary storing tab names and their corresponding containers. + + Examples + -------- + >>> tabform = Tabform() + >>> tabform.add_tab("Settings", container_type="scrollable") + >>> tabform.add_tab("Options", container_type="standard") + >>> tabform.populate_tab("Settings", some_components) + """ + + def __init__(self, tab_position: str = "top"): + super().__init__() + + self.widget = QTabWidget() + self.tabs = {} + + # Set tab position + position_map = {"top": QTabWidget.North, "bottom": QTabWidget.South, "left": QTabWidget.West, "right": QTabWidget.East} + self.widget.setTabPosition(position_map.get(tab_position, QTabWidget.North)) + + # Connect tab change signal + self.widget.currentChanged.connect(self.on_tab_changed) + + def add_tab(self, name: str, container: Container = None, container_type: str = "standard") -> Container: + """ + Add a new tab with the given name and container. + + Parameters + ---------- + name : str + The name/title of the tab. + container : Container, optional + The container to use for this tab. If None, a new container will be created. + container_type : str, optional + Type of container for the tab if container is None. Options are "standard", "scrollable", "splitter". + Defaults to "standard". + + Returns + ------- + Container + The container that was added to this tab. + """ + if name in self.tabs: + raise ValueError(f"Tab '{name}' already exists") + + # Use provided container or create a new one + if container is None: + container = Container(container_type=container_type) + + self.tabs[name] = container + + # Add the tab to the widget + self.widget.addTab(container.widget, name) + + return container + + def remove_tab(self, name: str) -> None: + """ + Remove a tab by name. + + Parameters + ---------- + name : str + The name of the tab to remove. + """ + if name not in self.tabs: + raise ValueError(f"Tab '{name}' does not exist") + + # Find the index of the tab + for i in range(self.widget.count()): + if self.widget.tabText(i) == name: + self.widget.removeTab(i) + break + + # Remove from our tracking dictionary + del self.tabs[name] + + def get_tab(self, name: str) -> Optional[Container]: + """ + Get the container for a specific tab. + + Parameters + ---------- + name : str + The name of the tab. + + Returns + ------- + Container or None + The container for the tab, or None if not found. + """ + return self.tabs.get(name) + + def populate_tab(self, tab_name: str, components: list) -> None: + """ + Populate a tab with components. + + Parameters + ---------- + tab_name : str + The name of the tab to populate. + components : list + List of components to add to the tab. + """ + if tab_name not in self.tabs: + raise ValueError(f"Tab '{tab_name}' does not exist") + + container = self.tabs[tab_name] + container.reset() + + for component in components: + container.add(component) + + def set_current_tab(self, name: str) -> None: + """ + Set the current active tab by name. + + Parameters + ---------- + name : str + The name of the tab to make active. + """ + if name not in self.tabs: + raise ValueError(f"Tab '{name}' does not exist") + + for i in range(self.widget.count()): + if self.widget.tabText(i) == name: + self.widget.setCurrentIndex(i) + break + + def get_current_tab_name(self) -> Optional[str]: + """ + Get the name of the currently active tab. + + Returns + ------- + str or None + The name of the current tab, or None if no tabs exist. + """ + current_index = self.widget.currentIndex() + if current_index >= 0: + return self.widget.tabText(current_index) + return None + + def on_tab_changed(self, index: int) -> None: + """ + Handle tab change events. + + Parameters + ---------- + index : int + The index of the newly selected tab. + """ + # This can be overridden by subclasses to handle tab changes + pass + + def update(self) -> None: + """Update all tabs and their contents.""" + super().update() + for container in self.tabs.values(): + container.update() + + def reset(self) -> None: + """Reset all tabs by removing all tabs and clearing the tabs dictionary.""" + # Remove all tabs + while self.widget.count() > 0: + self.widget.removeTab(0) + + # Clear the tabs dictionary + self.tabs.clear() + + def display_text(self, text: str, tab_name: str = "Info") -> None: + """ + Display text in a tab. If the tab doesn't exist, create it. + + Parameters + ---------- + text : str + The text to display. + tab_name : str, optional + The name of the tab to display the text in. Defaults to "Info". + """ + if tab_name not in self.tabs: + self.add_tab(tab_name, container_type="standard") + + container = self.tabs[tab_name] + container.display_text(text) diff --git a/src/compas_viewer/config.py b/src/compas_viewer/config.py index aeb17e6cd7..25af5294b9 100644 --- a/src/compas_viewer/config.py +++ b/src/compas_viewer/config.py @@ -10,7 +10,6 @@ from compas.colors import Color from compas_viewer.commands import Command -from compas_viewer.commands import camera_settings_cmd from compas_viewer.commands import capture_view_cmd from compas_viewer.commands import change_rendermode_cmd from compas_viewer.commands import change_view_cmd @@ -145,8 +144,6 @@ class MenubarConfig(ConfigBase): ], }, {"type": "separator"}, - {"title": camera_settings_cmd.title, "action": camera_settings_cmd}, - {"title": "Display Settings", "action": lambda: print("Display Settings")}, {"title": capture_view_cmd.title, "action": capture_view_cmd}, {"type": "separator"}, ], @@ -257,6 +254,7 @@ class SidebarConfig(ConfigBase): ], }, {"type": "ObjectSetting"}, + {"type": "CameraSetting"}, ] ) @@ -325,26 +323,6 @@ 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 @@ -400,7 +378,6 @@ class Config(ConfigBase): camera: CameraConfig = field(default_factory=CameraConfig) commands: list[Command] = field( default_factory=lambda: [ - camera_settings_cmd, change_rendermode_cmd, change_view_cmd, clear_scene_cmd, diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/ui/sidebar.py index 1cbb529e22..47b1ac01fb 100644 --- a/src/compas_viewer/ui/sidebar.py +++ b/src/compas_viewer/ui/sidebar.py @@ -1,8 +1,8 @@ -from typing import Callable - from compas_viewer.components import Sceneform +from compas_viewer.components.camerasetting import CameraSetting from compas_viewer.components.container import Container from compas_viewer.components.objectsetting import ObjectSetting +from compas_viewer.components.tabform import Tabform class SideBarRight(Container): @@ -17,12 +17,18 @@ class SideBarRight(Container): ---------- sceneform : Sceneform Sceneform component, if it is in the items list. + tabform : Tabform + TabForm component containing ObjectSetting and CameraSetting, if they are in the items list. object_setting : ObjectSetting - ObjectSetting component, if it is in the items list. + ObjectSetting component nested within the TabForm, if it is in the items list. + camera_setting : CameraSetting + CameraSetting component nested within the TabForm, if it is in the items list. + """ def __init__(self) -> None: super().__init__(container_type="splitter") + self.tabform = Tabform(tab_position="top") self.load_items() @property @@ -30,6 +36,8 @@ def items(self): return self.viewer.config.ui.sidebar.items def load_items(self): + tabform_added = False + for item in self.items: itemtype = item.get("type", None) @@ -42,4 +50,14 @@ def load_items(self): if itemtype == "ObjectSetting": self.object_setting = ObjectSetting() - self.add(self.object_setting) + self.tabform.add_tab("Object", container=self.object_setting) + if not tabform_added: + self.add(self.tabform) + tabform_added = True + + if itemtype == "CameraSetting": + self.camera_setting = CameraSetting() + self.tabform.add_tab("Camera", container=self.camera_setting) + if not tabform_added: + self.add(self.tabform) + tabform_added = True diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui/ui.py index 95784466c2..e17549e8ad 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui/ui.py @@ -30,5 +30,4 @@ def init(self): self.window.resize(self.viewer.config.window.width, self.viewer.config.window.height) self.window.widget.show() - self.sidebar.update() From acc66e1b0b24ce0ab57d05df77501d25c76544b7 Mon Sep 17 00:00:00 2001 From: Licini Date: Wed, 16 Jul 2025 17:15:20 +0200 Subject: [PATCH 14/18] Adds object binding with change watching support --- .../components/boundcomponent.py | 109 +++++++++++++++++- src/compas_viewer/components/numberedit.py | 30 ++++- src/compas_viewer/components/slider.py | 33 +++++- 3 files changed, 167 insertions(+), 5 deletions(-) diff --git a/src/compas_viewer/components/boundcomponent.py b/src/compas_viewer/components/boundcomponent.py index 38412d9f64..df6ae1fd4b 100644 --- a/src/compas_viewer/components/boundcomponent.py +++ b/src/compas_viewer/components/boundcomponent.py @@ -21,6 +21,9 @@ class BoundComponent(Component): The name of the attribute/key to be bound. action : Callable[[Component, float], None] A function to call when the value changes. Receives the component and new value. + watch_interval : int, optional + Interval in milliseconds to check for changes in the bound object. + If None, watching is disabled. Default is 100. Attributes ---------- @@ -30,6 +33,12 @@ class BoundComponent(Component): The name of the attribute/key being bound. action : Callable[[Component, float], None] The action function to call when the value changes. + watch_interval : int or None + The watching interval in milliseconds, or None if watching is disabled. Default is 100. + _watch_timer : Timer or None + The timer used for watching changes in the bound object. + _last_watched_value : Any + The last known value of the bound attribute from watching. Example ------- @@ -39,17 +48,28 @@ class BoundComponent(Component): >>> def my_action(component, value): ... print(f"Value changed to: {value}") >>> obj = MyObject() + >>> # Component with default watcher (100ms) >>> component = BoundComponent(obj, "value", my_action) - >>> component.set_attr(20.0) - >>> print(component.get_attr()) # prints 20.0 + >>> # Component without watcher + >>> component = BoundComponent(obj, "value", my_action, watch_interval=None) + >>> # Component with custom watcher interval + >>> component = BoundComponent(obj, "value", my_action, watch_interval=200) """ - def __init__(self, obj: Union[object, dict], attr: str, action: Callable[[Component, float], None]): + def __init__(self, obj: Union[object, dict], attr: str, action: Callable[[Component, float], None], watch_interval: int = 100): super().__init__() self.obj = obj self.attr = attr self.action = action + self.watch_interval = watch_interval + self._watch_timer = None + self._last_watched_value = None + self._updating_from_watch = False + + # Start watching if interval is provided + if self.watch_interval is not None: + self.start_watching() def get_attr(self): """ @@ -98,3 +118,86 @@ def on_value_changed(self, value: Any): self.set_attr(value) if self.action is not None: self.action(self, value) + + def start_watching(self): + """ + Start watching the bound object for changes. + + This method starts a timer that periodically checks if the bound attribute + has changed and updates the component accordingly. + """ + if self.watch_interval is None: + return + + if self._watch_timer is not None: + self.stop_watching() + + from compas_viewer.timer import Timer + + self._last_watched_value = self.get_attr() + self._watch_timer = Timer(self.watch_interval, self._check_for_changes) + + def stop_watching(self): + """ + Stop watching the bound object for changes. + """ + if self._watch_timer is not None: + self._watch_timer.stop() + self._watch_timer = None + + def _check_for_changes(self): + """ + Check if the bound attribute has changed and update the component if needed. + + This method is called periodically by the watch timer. + """ + if self._updating_from_watch: + return + + # Skip checking if widget is not visible to save resources + if not self.widget.isVisible(): + return + + current_value = self.get_attr() + if current_value != self._last_watched_value: + self._last_watched_value = current_value + self._updating_from_watch = True + try: + print("sync from bound object", self.obj, self.attr) + self.sync_from_bound_object(current_value) + finally: + self._updating_from_watch = False + + def sync_from_bound_object(self, value: Any): + """ + Sync the component's display with the bound object's value. + + This method should be overridden by subclasses to update their + specific UI elements when the bound object changes. + + Parameters + ---------- + value : Any + The new value from the bound object. + """ + # Base implementation does nothing - subclasses should override + pass + + def set_watch_interval(self, interval: int): + """ + Set or change the watch interval. + + Parameters + ---------- + interval : int or None + The new interval in milliseconds, or None to disable watching. + """ + was_watching = self._watch_timer is not None + + if was_watching: + self.stop_watching() + + self.watch_interval = interval + + if interval is not None: + self.start_watching() diff --git a/src/compas_viewer/components/numberedit.py b/src/compas_viewer/components/numberedit.py index 34811f7e5f..94893c8f73 100644 --- a/src/compas_viewer/components/numberedit.py +++ b/src/compas_viewer/components/numberedit.py @@ -34,6 +34,11 @@ class NumberEdit(BoundComponent): The number of decimal places to display. Defaults to 1. action : Callable[[Component, float], None], optional A function to call when the value changes. Receives the component and new value. + **kwargs + Additional keyword arguments passed to BoundComponent, including: + - watch_interval : int, optional + Interval in milliseconds to check for changes in the bound object. + If None, watching is disabled. Default is 100. Attributes ---------- @@ -58,7 +63,10 @@ class NumberEdit(BoundComponent): ... def __init__(self): ... self.x = 5.0 >>> obj = MyObject() + >>> # Component with default watcher (100ms) >>> component = NumberEdit(obj, "x", title="X Coordinate", min_val=0.0, max_val=10.0) + >>> # Component without watcher + >>> component = NumberEdit(obj, "x", title="X Coordinate", min_val=0.0, max_val=10.0, watch_interval=None) """ def __init__( @@ -71,8 +79,9 @@ def __init__( step: float = 0.1, decimals: int = 1, action: Callable[[Component, float], None] = None, + **kwargs, ): - super().__init__(obj, attr, action=action) + super().__init__(obj, attr, action=action, **kwargs) self.widget = QWidget() self.layout = QHBoxLayout() @@ -99,3 +108,22 @@ def __init__( self.layout.addWidget(self.spinbox) self.widget.setLayout(self.layout) self.spinbox.valueChanged.connect(self.on_value_changed) + + def sync_from_bound_object(self, value: float): + """ + Sync the spinbox value with the bound object's value. + + This method is called when the bound object's value changes externally. + + Parameters + ---------- + value : float + The new value from the bound object. + """ + # Temporarily disconnect the signal to prevent infinite loops + self.spinbox.valueChanged.disconnect(self.on_value_changed) + try: + self.spinbox.setValue(value) + finally: + # Reconnect the signal + self.spinbox.valueChanged.connect(self.on_value_changed) diff --git a/src/compas_viewer/components/slider.py b/src/compas_viewer/components/slider.py index d45228f6e2..aeaea8bcf9 100644 --- a/src/compas_viewer/components/slider.py +++ b/src/compas_viewer/components/slider.py @@ -42,6 +42,11 @@ class Slider(BoundComponent): Interval between tick marks. No ticks if None. Defaults to None. action : Callable[[Component, float], None], optional A function to call when the value changes. Receives the component and new value. + **kwargs + Additional keyword arguments passed to BoundComponent, including: + - watch_interval : int, optional + Interval in milliseconds to check for changes in the bound object. + If None, watching is disabled. Default is 100. Attributes ---------- @@ -96,8 +101,9 @@ def __init__( step: float = 1, tick_interval: Optional[float] = None, action: Callable[[Component, float], None] = None, + **kwargs, ): - super().__init__(obj, attr, action=action) + super().__init__(obj, attr, action=action, **kwargs) self.title = title if title is not None else (attr if attr is not None else "Value") self.min_val = min_val @@ -196,3 +202,28 @@ def on_text_changed(self): except ValueError: pass # Handle cases where the text is not a valid number self._updating = False + + def sync_from_bound_object(self, value: float): + """ + Sync the slider and text input with the bound object's value. + + This method is called when the bound object's value changes externally. + + Parameters + ---------- + value : float + The new value from the bound object. + """ + if self._updating: + return + + self._updating = True + try: + # Clamp value to valid range + clamped_value = max(self.min_val, min(self.max_val, value)) + + # Update both the slider and the text input + self.slider.setValue(self._scale_value(clamped_value)) + self.line_edit.setText(str(clamped_value)) + finally: + self._updating = False From eea0e4117d6a4e279607a2e976b7c6cc0cc203d5 Mon Sep 17 00:00:00 2001 From: Licini Date: Wed, 16 Jul 2025 17:23:21 +0200 Subject: [PATCH 15/18] move files --- src/compas_viewer/components/boundcomponent.py | 1 - src/compas_viewer/components/camerasetting.py | 2 +- src/compas_viewer/{ui => components}/mainwindow.py | 0 src/compas_viewer/{ui => components}/menubar.py | 0 src/compas_viewer/components/objectsetting.py | 6 +++--- src/compas_viewer/{ui => components}/sidebar.py | 0 src/compas_viewer/{ui => components}/sidedock.py | 0 src/compas_viewer/{ui => components}/statusbar.py | 0 src/compas_viewer/{ui => components}/toolbar.py | 0 src/compas_viewer/{ui => components}/viewport.py | 0 src/compas_viewer/{ui => }/ui.py | 12 ++++++------ src/compas_viewer/ui/__init__.py | 3 --- src/compas_viewer/ui/view3d.py | 3 --- 13 files changed, 10 insertions(+), 17 deletions(-) rename src/compas_viewer/{ui => components}/mainwindow.py (100%) rename src/compas_viewer/{ui => components}/menubar.py (100%) rename src/compas_viewer/{ui => components}/sidebar.py (100%) rename src/compas_viewer/{ui => components}/sidedock.py (100%) rename src/compas_viewer/{ui => components}/statusbar.py (100%) rename src/compas_viewer/{ui => components}/toolbar.py (100%) rename src/compas_viewer/{ui => components}/viewport.py (100%) rename src/compas_viewer/{ui => }/ui.py (77%) delete mode 100644 src/compas_viewer/ui/__init__.py delete mode 100644 src/compas_viewer/ui/view3d.py diff --git a/src/compas_viewer/components/boundcomponent.py b/src/compas_viewer/components/boundcomponent.py index df6ae1fd4b..1b5f2de1b9 100644 --- a/src/compas_viewer/components/boundcomponent.py +++ b/src/compas_viewer/components/boundcomponent.py @@ -163,7 +163,6 @@ def _check_for_changes(self): self._last_watched_value = current_value self._updating_from_watch = True try: - print("sync from bound object", self.obj, self.attr) self.sync_from_bound_object(current_value) finally: self._updating_from_watch = False diff --git a/src/compas_viewer/components/camerasetting.py b/src/compas_viewer/components/camerasetting.py index a43ffb555f..e5e3e73e49 100644 --- a/src/compas_viewer/components/camerasetting.py +++ b/src/compas_viewer/components/camerasetting.py @@ -79,7 +79,7 @@ def _update_camera(*args): self.far = NumberEdit(camera, "far", title="Far Plane", min_val=10.0, max_val=10000.0, action=_update_camera) self.scale = NumberEdit(camera, "scale", title="Scale", min_val=0.1, max_val=10.0, action=_update_camera) self.zoomdelta = NumberEdit(camera, "zoomdelta", title="Zoom Delta", min_val=0.001, max_val=1.0, action=_update_camera) - self.rotationdelta = NumberEdit(camera, "rotationdelta", title="Rotation Delta", min_val=0.001, max_val=1.0, action=_update_camera) + self.rotationdelta = NumberEdit(camera, "rotationdelta", title="Rotation Delta", step=0.01, decimals=2, min_val=0.001, max_val=1.0, action=_update_camera) self.pandelta = NumberEdit(camera, "pandelta", title="Pan Delta", min_val=0.001, max_val=1.0, action=_update_camera) # Add components to the form diff --git a/src/compas_viewer/ui/mainwindow.py b/src/compas_viewer/components/mainwindow.py similarity index 100% rename from src/compas_viewer/ui/mainwindow.py rename to src/compas_viewer/components/mainwindow.py diff --git a/src/compas_viewer/ui/menubar.py b/src/compas_viewer/components/menubar.py similarity index 100% rename from src/compas_viewer/ui/menubar.py rename to src/compas_viewer/components/menubar.py diff --git a/src/compas_viewer/components/objectsetting.py b/src/compas_viewer/components/objectsetting.py index aa1f2d3732..5841a9db0f 100644 --- a/src/compas_viewer/components/objectsetting.py +++ b/src/compas_viewer/components/objectsetting.py @@ -57,13 +57,13 @@ def _update_sceneform(*arg): if hasattr(obj, "show_faces"): self.add(BooleanToggle(obj=obj, attr="show_faces", action=_update_obj_settings)) - if hasattr(obj, "pointcolor"): + if hasattr(obj, "pointcolor") and obj.pointcolor is not None: self.add(ColorPicker(obj=obj, attr="pointcolor", action=_update_obj_settings)) - if hasattr(obj, "linecolor"): + if hasattr(obj, "linecolor") and obj.linecolor is not None: self.add(ColorPicker(obj=obj, attr="linecolor", action=_update_obj_settings)) - if hasattr(obj, "facecolor"): + if hasattr(obj, "facecolor") and obj.facecolor is not None: self.add(ColorPicker(obj=obj, attr="facecolor", action=_update_obj_settings)) if hasattr(obj, "linewidth"): diff --git a/src/compas_viewer/ui/sidebar.py b/src/compas_viewer/components/sidebar.py similarity index 100% rename from src/compas_viewer/ui/sidebar.py rename to src/compas_viewer/components/sidebar.py diff --git a/src/compas_viewer/ui/sidedock.py b/src/compas_viewer/components/sidedock.py similarity index 100% rename from src/compas_viewer/ui/sidedock.py rename to src/compas_viewer/components/sidedock.py diff --git a/src/compas_viewer/ui/statusbar.py b/src/compas_viewer/components/statusbar.py similarity index 100% rename from src/compas_viewer/ui/statusbar.py rename to src/compas_viewer/components/statusbar.py diff --git a/src/compas_viewer/ui/toolbar.py b/src/compas_viewer/components/toolbar.py similarity index 100% rename from src/compas_viewer/ui/toolbar.py rename to src/compas_viewer/components/toolbar.py diff --git a/src/compas_viewer/ui/viewport.py b/src/compas_viewer/components/viewport.py similarity index 100% rename from src/compas_viewer/ui/viewport.py rename to src/compas_viewer/components/viewport.py diff --git a/src/compas_viewer/ui/ui.py b/src/compas_viewer/ui.py similarity index 77% rename from src/compas_viewer/ui/ui.py rename to src/compas_viewer/ui.py index e17549e8ad..3158bb02ee 100644 --- a/src/compas_viewer/ui/ui.py +++ b/src/compas_viewer/ui.py @@ -1,11 +1,11 @@ from compas_viewer.base import Base -from .mainwindow import MainWindow -from .menubar import MenuBar -from .sidedock import SideDock -from .statusbar import StatusBar -from .toolbar import ToolBar -from .viewport import ViewPort +from .components.mainwindow import MainWindow +from .components.menubar import MenuBar +from .components.sidedock import SideDock +from .components.statusbar import StatusBar +from .components.toolbar import ToolBar +from .components.viewport import ViewPort class UI(Base): diff --git a/src/compas_viewer/ui/__init__.py b/src/compas_viewer/ui/__init__.py deleted file mode 100644 index c744b23426..0000000000 --- a/src/compas_viewer/ui/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .ui import UI - -__all__ = ["UI"] diff --git a/src/compas_viewer/ui/view3d.py b/src/compas_viewer/ui/view3d.py deleted file mode 100644 index 2e6f4e9cd4..0000000000 --- a/src/compas_viewer/ui/view3d.py +++ /dev/null @@ -1,3 +0,0 @@ -class View3d: - def __init__(self) -> None: - pass From 3297c27d1ed3504b4b9f6efdf34e002eb68ef4ca Mon Sep 17 00:00:00 2001 From: Licini Date: Wed, 16 Jul 2025 17:26:29 +0200 Subject: [PATCH 16/18] remove unused --- src/compas_viewer/components/__init__.py | 4 - src/compas_viewer/components/combobox.py | 165 ----------------------- src/compas_viewer/components/label.py | 100 -------------- src/compas_viewer/components/lineedit.py | 57 -------- 4 files changed, 326 deletions(-) delete mode 100644 src/compas_viewer/components/combobox.py delete mode 100644 src/compas_viewer/components/label.py delete mode 100644 src/compas_viewer/components/lineedit.py diff --git a/src/compas_viewer/components/__init__.py b/src/compas_viewer/components/__init__.py index 7e505c3162..b7e4b93780 100644 --- a/src/compas_viewer/components/__init__.py +++ b/src/compas_viewer/components/__init__.py @@ -1,6 +1,4 @@ from .button import Button -from .combobox import ComboBox -from .combobox import ViewModeAction from .camerasetting import CameraSetting from .slider import Slider from .textedit import TextEdit @@ -12,7 +10,6 @@ __all__ = [ "Button", - "ComboBox", "CameraSetting", "Renderer", "Slider", @@ -22,5 +19,4 @@ "ObjectSetting", "Tabform", "Component", - "ViewModeAction", ] diff --git a/src/compas_viewer/components/combobox.py b/src/compas_viewer/components/combobox.py deleted file mode 100644 index 79fdf0dc26..0000000000 --- a/src/compas_viewer/components/combobox.py +++ /dev/null @@ -1,165 +0,0 @@ -from typing import Callable -from typing import Optional - -from PySide6.QtCore import QSize -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor -from PySide6.QtGui import QPainter -from PySide6.QtWidgets import QComboBox -from PySide6.QtWidgets import QStyledItemDelegate -from PySide6.QtWidgets import QVBoxLayout -from PySide6.QtWidgets import QWidget - -from compas_viewer.base import Base - - -class ColorDelegate(QStyledItemDelegate): - def paint(self, painter, option, index): - painter.save() - - # Get the color from the model data - color = index.data(Qt.UserRole) - if isinstance(color, QColor): - # Draw the color rectangle - painter.fillRect(option.rect, color) - painter.restore() - - def sizeHint(self, option, index): - # Set a fixed size for the items - return QSize(100, 20) - - -class ComboBox(QComboBox): - """ - A customizable combo box widget, supporting item-specific rendering with an optional color delegate. - - Parameters - ---------- - items : list - List of items to populate the combo box. - change_callback : Callable - Function to execute on value change. - Should accept a single argument corresponding to the selected item's data. - paint : bool, optional - Whether to use a custom delegate for item rendering, such as displaying colors. Defaults to None. - - Attributes - ---------- - paint : bool - Flag indicating whether custom item rendering is enabled. - assigned_color : QColor or None - The color assigned to the combo box before any changes. - is_changed : bool - Indicates if the color has been changed through user interaction. - - Methods - ------- - populate(items: list) -> None - Populates the combo box with items. - setAssignedColor(color: QColor) -> None - Sets the assigned color to be displayed when the item is not changed. - on_index_changed(change_callback: Callable, index: int) -> None - Handles the index change event and triggers the callback. - paintEvent(event) -> None - Custom painting for the combo box, used when `paint` is True. - - Example - ------- - >>> items = [("Red", QColor(255, 0, 0)), ("Green", QColor(0, 255, 0)), ("Blue", QColor(0, 0, 255))] - >>> combobox = ComboBox(items, change_callback=lambda x: print(x), paint=True) - """ - - def __init__( - self, - items: list = None, - change_callback: Callable = None, - paint: Optional[bool] = None, - ): - super().__init__() - self.paint = paint - self.assigned_color = None - self.is_changed = False - - if self.paint: - self.setItemDelegate(ColorDelegate()) - - if items: - self.populate(items) - - if change_callback: - self.currentIndexChanged.connect(lambda index: self.on_index_changed(change_callback, index)) - - def populate(self, items: list) -> None: - """ - Populate the combo box with items. - - Parameters - ---------- - items : list - List of tuples, each containing the display text and user data - """ - for item in items: - if self.paint: - self.addItem("", item) - index = self.model().index(self.count() - 1, 0) - self.model().setData(index, item, Qt.UserRole) - else: - self.addItem(item, item) - - def setAssignedColor(self, color: QColor) -> None: - """ - Sets the assigned color to be displayed when the item is not changed. - - Parameters - ---------- - color : QColor - The color to be assigned to the combo box. - """ - self.assigned_color = color - - def on_index_changed(self, change_callback: Callable, index: int) -> None: - """ - Handles the index change event and triggers the callback. - - Parameters - ---------- - change_callback : Callable - Function to execute on value change. - index : int - The new index of the selected item. - """ - self.is_changed = True - change_callback(self.itemData(index)) - self.update() - - def paintEvent(self, event) -> None: - painter = QPainter(self) - rect = self.rect() - - color = self.currentData(Qt.UserRole) if self.is_changed else self.assigned_color - - if isinstance(color, QColor): - painter.fillRect(rect, color) - else: - super().paintEvent(event) - painter.end() - - -class ViewModeAction(QWidget, Base): - def __init__(self): - super().__init__() - self.view_options = [ - "perspective", - "top", - "front", - "right", - ] - - def combobox(self): - self.layout = QVBoxLayout(self) - self.view_selector = ComboBox(self.view_options, self.change_view) - self.layout.addWidget(self.view_selector) - return self - - def change_view(self, mode): - self.viewer.renderer.view = mode diff --git a/src/compas_viewer/components/label.py b/src/compas_viewer/components/label.py deleted file mode 100644 index 8a119c9f8f..0000000000 --- a/src/compas_viewer/components/label.py +++ /dev/null @@ -1,100 +0,0 @@ -from typing import Literal -from typing import Optional - -from PySide6 import QtCore -from PySide6 import QtGui -from PySide6 import QtWidgets - - -class LabelWidget(QtWidgets.QWidget): - """ - A customizable QLabel widget for Qt applications, supporting text alignment and font size adjustments. - - Parameters - ---------- - text : str - The text to be displayed in the label. - alignment : Literal["right", "left", "center"], optional - The alignment of the text in the label. Defaults to "center". - font_size : int, optional - The font size of the text in the label. Defaults to 8. - - Attributes - ---------- - text : str - The text displayed in the label. - alignment : Literal["right", "left", "center"] - The alignment of the text in the label. - font_size : int - The font size of the text in the label. - - Methods - ------- - update_minimum_size() -> None - Updates the minimum size of the label based on the current text and font size. - - Example - ------- - >>> label_widget = LabelWidget("Ready...", alignment="right", font_size=16) - >>> label_widget.show() - """ - - def __init__(self, text: str, alignment: Literal["right", "left", "center"] = "center", font_size: Optional[int] = 8): - super().__init__() - - self.label = QtWidgets.QLabel(self) - - self.text = text - self.font_size = font_size - self.alignment = alignment - - self.layout = QtWidgets.QHBoxLayout(self) - self.layout.addWidget(self.label) - self.setLayout(self.layout) - - 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() - - @text.setter - def text(self, value: str): - self.label.setText(value) - - @property - def alignment(self): - return self.label.alignment() - - @alignment.setter - def alignment(self, value: str): - alignments = { - "right": QtCore.Qt.AlignRight, - "left": QtCore.Qt.AlignLeft, - "center": QtCore.Qt.AlignCenter, - } - self.label.setAlignment(alignments[value]) - - @property - def font_size(self): - return self.label.font().pointSize() - - @font_size.setter - def font_size(self, value: int): - font = self.label.font() - font.setPointSize(value) - self.label.setFont(font) - - def update_minimum_size(self): - font_metrics = QtGui.QFontMetrics(self.label.font()) - text_width = font_metrics.horizontalAdvance(self.label.text()) - text_height = font_metrics.height() - self.label.setMinimumSize(text_width, text_height) diff --git a/src/compas_viewer/components/lineedit.py b/src/compas_viewer/components/lineedit.py deleted file mode 100644 index a3700710f2..0000000000 --- a/src/compas_viewer/components/lineedit.py +++ /dev/null @@ -1,57 +0,0 @@ -from typing import Callable -from typing import Optional - -from PySide6.QtCore import Qt - -# from PySide6.QtGui import QDoubleValidator -from PySide6.QtWidgets import QHBoxLayout -from PySide6.QtWidgets import QLabel -from PySide6.QtWidgets import QLineEdit -from PySide6.QtWidgets import QSizePolicy -from PySide6.QtWidgets import QWidget - - -class LineEdit(QWidget): - """ - A customizable QTextEdit widget for Qt applications, supporting text alignment and font size adjustments. - """ - - def __init__( - self, - text: Optional[str] = None, - label: Optional[str] = None, - action: Optional[Callable] = None, - ): - super().__init__() - - self.action = action - - label = QLabel(label) - label.setStyleSheet("""margin-right: 8px;""") - - self.text_edit = QLineEdit() - self.text_edit.setMaximumSize(85, 25) - self.text_edit.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - self.text_edit.setText(text) - self.text_edit.setStyleSheet("""padding: 2px;""") - - # validator = QDoubleValidator() - - self.layout = QHBoxLayout() - self.layout.setSpacing(0) - self.layout.setContentsMargins(0, 0, 0, 0) - self.layout.setAlignment(Qt.AlignLeft) - self.layout.addWidget(label) - self.layout.addWidget(self.text_edit) - self.setLayout(self.layout) - - self.text_edit.returnPressed.connect(self.text_update) - - def text_update(self): - try: - value = float(self.text_edit.text()) - except ValueError: - pass - else: - if self.action: - self.action(self, value) From de18b104436f8992f6159df20fca864e5d358a59 Mon Sep 17 00:00:00 2001 From: Licini Date: Wed, 16 Jul 2025 17:40:20 +0200 Subject: [PATCH 17/18] changlog --- CHANGELOG.md | 16 +++++++++++++++ UI_Component_Refactoring_Summary.md | 32 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 UI_Component_Refactoring_Summary.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e5d69c72..b5c0cb7415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added `Component` base class with standardized `widget` attribute and `update()` method. +* Added `BoundComponent` class for components bound to object attributes with automatic value synchronization. +* Added new components: `BooleanToggle`, `ColorPicker`, `NumberEdit`, `Container`, `Tabform`. + ### Changed +* Complete restructuring of compas_viewer UI architecture to implement component-based system. +* Replaced dialogs with integrated components: `CameraSettingsDialog` → `CameraSetting`, `ObjectSettingDialog` → `ObjectSetting`. +* Enhanced existing components: Updated `Slider`, `TextEdit`, `Button` to use new component-based system. +* Moved UI elements to dedicated `components/` folder. +* Refactored `MenuBar`, `ToolBar`, `SideDock`, `MainWindow`, `StatusBar`, `ViewPort` to use new component system. +* Updated `UI` class to use new component architecture. +* All UI components now inherit from `Base` class for consistent structure. +* Improved data binding with automatic attribute synchronization. + ### Removed +* Removed deprecated components: `ColorComboBox`, `ComboBox`, `DoubleEdit`, `LineEdit`, `LabelWidget`. +* Removed `CameraSettingsDialog` and `ObjectSettingDialog` (replaced with integrated components). + ## [1.6.1] 2025-06-30 diff --git a/UI_Component_Refactoring_Summary.md b/UI_Component_Refactoring_Summary.md new file mode 100644 index 0000000000..36b8215cae --- /dev/null +++ b/UI_Component_Refactoring_Summary.md @@ -0,0 +1,32 @@ +# UI Component System Refactoring + +## Overview +Complete restructuring of the compas_viewer UI architecture to implement a modern component-based system with improved modularity and maintainability. + +## Key Changes + +### New Component Architecture +- Added `Component` base class with standardized `widget` attribute and `update()` method +- Added `BoundComponent` class for components bound to object attributes with automatic value synchronization +- All UI components now inherit from `Base` class for consistent structure + +### Component Refactoring +- **Replaced dialogs with integrated components**: `CameraSettingsDialog` → `CameraSetting`, `ObjectSettingDialog` → `ObjectSetting` +- **Enhanced existing components**: Updated `Slider`, `TextEdit`, `Button` to use new inheritance model +- **Added new components**: `BooleanToggle`, `ColorPicker`, `NumberEdit`, `Container`, `Tabform` +- **Removed deprecated components**: `ColorComboBox`, `ComboBox`, `DoubleEdit`, `LineEdit`, `LabelWidget` + +### UI Structure Improvements +- Moved components to dedicated `components/` folder +- Added `MainWindow`, `StatusBar`, `ViewPort` components +- Refactored `MenuBar`, `ToolBar`, `SideDock` to use new component system +- Updated `UI` class to use new component architecture + +### Technical Improvements +- Standardized component initialization with `obj`, `attr`, `action` parameters +- Improved data binding with automatic attribute synchronization +- Enhanced container system with scrollable and splitter options +- Updated event handling and signal connections + +## Impact +This refactoring provides a cleaner, more maintainable codebase with better separation of concerns and improved extensibility for future UI development. \ No newline at end of file From cd9565b6624637c8ef9742b9596a98d3a7e5b4e4 Mon Sep 17 00:00:00 2001 From: Licini Date: Wed, 16 Jul 2025 17:44:28 +0200 Subject: [PATCH 18/18] docs error --- docs/api/compas_viewer.components.rst | 7 +++++++ docs/api/compas_viewer.ui.rst | 16 ---------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/docs/api/compas_viewer.components.rst b/docs/api/compas_viewer.components.rst index b1783f5201..c8c8fd81f9 100644 --- a/docs/api/compas_viewer.components.rst +++ b/docs/api/compas_viewer.components.rst @@ -18,3 +18,10 @@ Classes Slider Treeform ViewModeAction + mainwindow.MainWindow + menubar.MenuBar + sidebar.SideBarRight + sidedock.SideDock + statusbar.StatusBar + toolbar.ToolBar + viewport.ViewPort diff --git a/docs/api/compas_viewer.ui.rst b/docs/api/compas_viewer.ui.rst index d6e4f539e5..6f56ee629c 100644 --- a/docs/api/compas_viewer.ui.rst +++ b/docs/api/compas_viewer.ui.rst @@ -12,19 +12,3 @@ Classes :nosignatures: UI - - -Other Classes -============= - -.. autosummary:: - :toctree: generated/ - :nosignatures: - - mainwindow.MainWindow - menubar.MenuBar - sidebar.SideBarRight - sidedock.SideDock - statusbar.StatusBar - toolbar.ToolBar - viewport.ViewPort \ No newline at end of file